diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5825a53..004f49f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -15,7 +15,7 @@ "repository": "https://github.com/mubit-ai/plugins", "license": "Apache-2.0", "keywords": ["memory", "mubit", "long-term-memory", "lessons", "recall"], - "contextCost": { "value": 3125, "cached": 0 } + "contextCost": { "value": 3185, "cached": 0 } } ] } diff --git a/.githooks/pre-push b/.githooks/pre-push index 392365a..ea4b754 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -6,8 +6,9 @@ # workflow starts, GitHub is already serving the commit, and a later force-push does not # un-publish a blob that anyone can still fetch by SHA. CI here is a detector. This is the gate. # -# It scans **the commits being pushed**, not the working tree, because pushing a branch you are -# not standing on is ordinary and scanning the wrong tree would pass. +# It scans **every commit being pushed**, not the working tree and not only the tip: pushing a +# branch you are not standing on is ordinary, and a commit that adds a leak stays fetchable by +# SHA after a later one deletes it. # # Install (once per clone; it covers every worktree): # @@ -42,20 +43,42 @@ fi status=0 checked='' +# Files a commit adds or changes relative to its first parent; every file for a root commit. +changed_files() { + git diff --name-only -z --diff-filter=AMRC "$1^" "$1" 2>/dev/null || git ls-tree -r -z --name-only "$1" +} + while read -r local_ref local_sha remote_ref remote_sha; do [ -n "${local_sha:-}" ] || continue [ "$local_sha" = "$ZERO" ] && continue # deleting a remote branch - # Several refs in one push often share a tip; scan each commit once. - case " $checked " in *" $local_sha "*) continue ;; esac - checked="$checked $local_sha" - - short=$(git rev-parse --short "$local_sha" 2>/dev/null || echo "$local_sha") - printf 'leakcheck: scanning %s (%s)\n' "$short" "${local_ref##*/}" >&2 - - if ! node "$SCANNER" --strict --quiet --rev "$local_sha" --no-annotations >&2; then - status=1 + # Every commit the remote does not have yet is published, not only the tip: a leak added in + # one commit and deleted in the next is still fetchable by SHA. A remote tip we cannot resolve + # (someone else pushed) falls back to "not on any remote-tracking branch". + if [ "$remote_sha" = "$ZERO" ] || ! commits=$(git rev-list "$remote_sha..$local_sha" 2>/dev/null); then + commits=$(git rev-list "$local_sha" --not --remotes 2>/dev/null) fi + [ -n "$commits" ] || commits=$local_sha + + for sha in $commits; do + # Several refs in one push often share commits; scan each one once. + case " $checked " in *" $sha "*) continue ;; esac + checked="$checked $sha" + + short=$(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") + + # The tip gets the whole-tree scan. An earlier commit can only add a leak in a file it + # touched, so it is scanned on those files alone — a full scan per commit is ~20 s. + if [ "$sha" = "$local_sha" ]; then + printf 'leakcheck: scanning %s (%s)\n' "$short" "${local_ref##*/}" >&2 + node "$SCANNER" --strict --quiet --rev "$sha" --no-annotations >&2 || status=1 + else + [ -n "$(changed_files "$sha" | tr -d '\0')" ] || continue + printf 'leakcheck: scanning %s (%s, changed files)\n' "$short" "${local_ref##*/}" >&2 + changed_files "$sha" | xargs -0 node "$SCANNER" --strict --quiet --no-annotations --rev "$sha" --paths >&2 \ + || status=1 + fi + done done if [ "$status" -ne 0 ]; then diff --git a/.github/scripts/leakcheck.mjs b/.github/scripts/leakcheck.mjs index 5f8919e..2159cd6 100644 --- a/.github/scripts/leakcheck.mjs +++ b/.github/scripts/leakcheck.mjs @@ -267,7 +267,8 @@ function isTextPath(path) { const ext = extname(path).toLowerCase(); if (CONFIG.textExtensions.includes(ext)) return true; // Extensionless files that are conventionally text. - return ['LICENSE', 'README', 'Makefile', 'Dockerfile'].includes(path.split('/').pop() || ''); + return ['LICENSE', 'README', 'Makefile', 'Dockerfile', '.gitignore', '.gitattributes', '.npmignore', + '.npmrc', '.editorconfig', '.nvmrc'].includes(path.split('/').pop() || ''); } function ruleApplies(rule, path) { diff --git a/.github/scripts/leakcheck.selftest.mjs b/.github/scripts/leakcheck.selftest.mjs index 65fe717..92d0fc3 100644 --- a/.github/scripts/leakcheck.selftest.mjs +++ b/.github/scripts/leakcheck.selftest.mjs @@ -229,6 +229,12 @@ const CASES = [ ].join('\n'), expect: ['tenancy-collapse', 'isolation-defect-disclosure'], }, + { + // Extensionless dotfiles are text, and they are published like everything else. + path: 'b/.gitignore', + body: '# bundles are committed artifacts (build-guide section 11)\n*.local\n', + expect: ['internal-doc-reference'], + }, ]; let failures = 0; @@ -315,6 +321,51 @@ try { rmSync(root, { recursive: true, force: true }); } +/** + * A push publishes every commit it carries, not only the tip, and a blob stays fetchable by SHA + * after a later commit deletes it. So the hook must refuse a branch whose leak lives only in an + * intermediate commit — and must still let a clean branch through. + */ +function checkPrePushScansEveryCommit() { + const repo = mkdtempSync(join(tmpdir(), 'leakcheck-prepush-')); + const g = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const push = (sha) => { + try { + execFileSync('sh', [join(repo, '.githooks/pre-push'), 'origin', 'https://example.invalid/r.git'], { + cwd: repo, input: `refs/heads/x ${sha} refs/heads/x ${'0'.repeat(40)}\n`, stdio: ['pipe', 'pipe', 'pipe'], + }); + return 0; + } catch (e) { + return /** @type {any} */ (e).status ?? 1; + } + }; + try { + g('init', '-q'); + g('config', 'user.email', 'selftest@example.com'); + g('config', 'user.name', 'selftest'); + cpSync(SOURCE_GITHUB, join(repo, '.github'), { recursive: true }); + rmSync(join(repo, '.github/leakcheck/baseline.json'), { force: true }); + cpSync(join(SOURCE_GITHUB, '..', '.githooks'), join(repo, '.githooks'), { recursive: true }); + writeFileSync(join(repo, 'README.md'), 'A clean tree.\n'); + g('add', '-A'); + g('commit', '-qm', 'clean'); + const clean = g('rev-parse', 'HEAD').trim(); + if (push(clean) !== 0) fail('pre-push refused a branch with no findings'); + + writeFileSync(join(repo, 'leak.mjs'), '// see crates/control/src/overlay.rs\nexport {};\n'); + g('add', '-A'); + g('commit', '-qm', 'leak'); + rmSync(join(repo, 'leak.mjs')); + g('add', '-A'); + g('commit', '-qm', 'remove it again'); + const tip = g('rev-parse', 'HEAD').trim(); + if (push(tip) === 0) fail('pre-push passed a branch whose leak is only in an intermediate commit'); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +} +checkPrePushScansEveryCommit(); + if (failures) { process.stdout.write(`\nleakcheck.selftest: ${failures} failure(s) — the gate is not seeing what it claims to.\n`); process.exit(1); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3a5fd5..edf2b6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,8 +40,8 @@ the hook-timing assertions and produces failures that are about your machine, no cd integrations/codex && npm test ``` -`npm ci` does not work here. `package.json` names a `file:../mcp` sibling that lives in the -source repository, so resolving the tree fails before it starts. One test needs a real parser +`npm ci` does not work here: `package.json` has a development dependency this repository does +not carry, so resolving the tree fails before it starts. One test needs a real parser (`test/engine-floor.test.mjs` checks that the shipped bundle parses on the oldest Node we claim to support), so install that single package out-of-tree and copy it in, the way CI does: @@ -68,8 +68,7 @@ git diff -- hooks/dist mcp/dist/index.js bin ``` A clean diff means the committed artifacts already match their source. `MUBIT_CC_BUILD_SKIP_SERVER=1` -skips the vendored MCP server, which is built from the private sibling package and cannot be -regenerated from this tree. Do not run `npm run clean` here: it deletes `mcp/dist/server.js`, +skips the vendored MCP server, which cannot be regenerated from this tree. Do not run `npm run clean` here: it deletes `mcp/dist/server.js`, and nothing in this repository can rebuild it. Anything under `integrations/claude-code/lib/` or `hooks/src/` is shared by both plugins, so a diff --git a/README.md b/README.md index 38d2a92..ecb2e8e 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ mubit_memory_health what is actually stored ``` A Codex turn on the same demo service, after the suite passed. The model credits the lesson -that was injected for the task (`mubit_outcome`, which raised its confidence to 0.6) and +that was injected for the task (`mubit_outcome`) and stores what it learned (`mubit_learned`, accepted and queued). Neither call was asked for.

@@ -359,8 +359,10 @@ the default. The ones worth knowing about: | `recallAsync` | `false` | Never make a prompt wait on recall, at the cost of one turn of staleness. | | `preToolWarnings` | `false` | Show the model a stored rule just before an `rm` or `git push`. It only ever warns. | | `capture` / `recall` | `true` | Turn either half off. | +| `sessionScore` | `full` | The memory scorecard printed under each reply that showed a lesson. `compact` for one line, `off` to hide it. | +| `outcomeReview` | `stop` | Asks Claude once per turn to credit the lessons that helped or misled it. `nudge` keeps only a one-line ask. | -The [Claude Code guide](integrations/claude-code/README.md#configuration) documents all 25. +The [Claude Code guide](integrations/claude-code/README.md#configuration) documents all 27. ## When something looks wrong @@ -409,7 +411,7 @@ Both hosts execute these directories as fetched, with no build step, which is wh ## Documentation and support - **Guides** — [Claude Code](integrations/claude-code/README.md) · - [Codex CLI](integrations/codex/README.md). Install, all 25 options, and troubleshooting. + [Codex CLI](integrations/codex/README.md). Install, all 27 options, and troubleshooting. - **Reference** — [docs.mubit.ai](https://docs.mubit.ai) for the Mubit API, SDKs and console. - **Keys and instances** — the [Mubit console](https://console.mubit.ai). - **Bugs** — [open an issue](https://github.com/mubit-ai/plugins/issues). What to put in diff --git a/docs/assets/claude-code-standing-rule-saved.png b/docs/assets/claude-code-standing-rule-saved.png index aee9a4d..cdcaebc 100644 Binary files a/docs/assets/claude-code-standing-rule-saved.png and b/docs/assets/claude-code-standing-rule-saved.png differ diff --git a/docs/assets/codex-mubit-outcome-and-learned.png b/docs/assets/codex-mubit-outcome-and-learned.png index e19b880..d1fe466 100644 Binary files a/docs/assets/codex-mubit-outcome-and-learned.png and b/docs/assets/codex-mubit-outcome-and-learned.png differ diff --git a/docs/assets/dashboard-lessons-counters.png b/docs/assets/dashboard-lessons-counters.png index 5162cb8..63cc031 100644 Binary files a/docs/assets/dashboard-lessons-counters.png and b/docs/assets/dashboard-lessons-counters.png differ diff --git a/docs/assets/dashboard-turn-injected-and-outcome.png b/docs/assets/dashboard-turn-injected-and-outcome.png index 17d400c..fba55a9 100644 Binary files a/docs/assets/dashboard-turn-injected-and-outcome.png and b/docs/assets/dashboard-turn-injected-and-outcome.png differ diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index 23c46af..44da879 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -32,7 +32,7 @@ "recallMaxPerSection": { "type": "number", "title": "Recall items per section", "default": 0, "description": "Maximum items rendered per section of the injected block. 0 means no cap, and the token budget is the only limit." }, "recallAssemble": { "type": "string", "title": "Recall assembly", "default": "client", - "description": "How recalled memory is assembled: 'client' is free; 'server' uses /v2/control/context, which costs two LLM calls per prompt." }, + "description": "How recalled memory is assembled: 'client' assembles it locally; 'server' uses /v2/control/context, which is slower on every prompt." }, "recallRepeatMode": { "type": "string", "title": "Repeated memory", "default": "pointer", "description": "What to do with a memory already injected earlier in this run: 'pointer' repeats it as a one-line reference, 'full' re-sends the whole entry on every prompt. Recall injection is the plugin's largest recurring context cost." }, "recallRankBy": { "type": "string", "title": "Recall ranking", "default": "auto", @@ -40,17 +40,21 @@ "recallAsync": { "type": "boolean", "title": "Non-blocking recall", "default": false, "description": "Never make a prompt wait on recall. The block injected before each prompt is the one retrieved in the background just after the previous prompt, so the hook returns instantly however slow the endpoint is. Costs one turn of staleness, and the first prompt of a session gets no recalled memory." }, "reflectOnEnd": { "type": "boolean", "title": "Reflect on session end", "default": true, - "description": "Reflect when a session ends. This is the only path that promotes a lesson beyond its own run — turning it off costs cross-session memory." }, + "description": "Reflect when a session ends. Lessons learned in a run reach later sessions only through it — turning it off costs cross-session memory." }, "sessionEndDetach": { "type": "boolean", "title": "Finish the session flush in the background", "default": true, - "description": "Let the end-of-session drain and reflection finish in a detached process. The host cancels the session-end hook about a second in when a session is torn down, and everything still running inside it dies with it — including the only call that promotes a lesson beyond its own run. Turning this off keeps that work in the hook, where a teardown can cut it short." }, + "description": "Let the end-of-session drain and reflection finish in a detached process. The host cancels the session-end hook about a second in when a session is torn down, and everything still running inside it dies with it — including the reflect that carries lessons into later sessions. Turning this off keeps that work in the hook, where a teardown can cut it short." }, "outcomeMode": { "type": "string", "title": "Outcome attribution", "default": "implicit", "description": "How turn outcomes are attributed back to recalled memories: off, implicit, or explicit." }, + "sessionScore": { "type": "string", "title": "Session memory scorecard", "default": "full", + "description": "After each turn that showed a lesson, print a scorecard under Claude's reply: lessons shown this session, how many the replies used, and whether those turns worked, failed or are waiting on your reply. 'full' is a short tree, 'compact' one line, 'off' nothing. Local only: it reads a log on disk and makes no network call." }, + "outcomeReview": { "type": "string", "title": "Outcome review", "default": "stop", + "description": "How Claude is asked to credit the memory it used. Every injected memory line starts with a short id like [m7k2q] that mubit_outcome accepts. 'nudge' adds one sentence asking Claude to credit what helped or misled it before finishing, and keeps mubit_outcome and mubit_learned loaded. 'stop' also has the Stop hook ask once per turn for a short review of that turn's lessons; it costs one extra short step, and Claude Code labels that step 'Stop hook error occurred' although nothing failed. 'off' does neither." }, "statusLine": { "type": "boolean", "title": "Status line", "default": true, "description": "Show Mubit connection and capture stats in the status line." }, "preToolWarnings": { "type": "boolean", "title": "Warn before matching tool calls", "default": false, "description": "Show the model a stored Mubit rule just before a matching Bash command runs (rm, git push). It only ever warns: it never blocks, rewrites or asks about a tool call, and it is a memory-informed reminder rather than a security boundary — use permissions for a hard allow or deny. Off by default." }, "resumeBlock": { "type": "boolean", "title": "Brief me on where I left off", "default": true, - "description": "At the start of a session, assemble a short summary of where earlier work on this project left off and put it in front of your first prompt. It is built in the background, so nothing waits for it, and it costs one round trip and two LLM calls per SESSION rather than per prompt. It is a briefing and not a task list. Only new and resumed sessions get one: /clear starts a fresh run with no history, and a compaction or a fork is already re-anchored." }, + "description": "At the start of a session, assemble a short summary of where earlier work on this project left off and put it in front of your first prompt. It is built in the background, so nothing waits for it, and it costs one slower request per SESSION rather than per prompt. It is a briefing and not a task list. Only new and resumed sessions get one: /clear starts a fresh run with no history, and a compaction or a fork is already re-anchored." }, "mcpTools": { "type": "string", "title": "MCP tool allowlist", "default": "", "description": "Comma-separated allowlist of Mubit MCP tools. Blank uses the curated default set." }, "mcpLessonScope": { "type": "string", "title": "MCP lesson scope ceiling", "default": "session", diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md index 5fe77e0..206a8f9 100644 --- a/integrations/claude-code/README.md +++ b/integrations/claude-code/README.md @@ -2,7 +2,7 @@ `mubit-memory` gives Claude Code persistent, typed memory backed by a [Mubit](https://mubit.ai) instance. It captures your tool activity involuntarily — you never have to remember to save -anything — recalls relevant lessons before every prompt at zero LLM cost, attributes the +anything — recalls relevant lessons before every prompt, attributes the outcome of each turn back to the memories that were injected so retrieval improves with use, and scrubs secrets out of everything before it leaves the machine. @@ -165,7 +165,7 @@ its output reached the model. ### A status line ``` -● mubit: cc-my-project-9f2a11c4 · hosted · recall 6/1.2k tok · saved 12t/1q · lessons 3g +● mubit: cc-my-project- · hosted · recall 6/1.2k tok · saved 12t/1q · lessons 3g ``` It reads two local JSON files and never touches the network, so a dead server can never freeze @@ -301,7 +301,7 @@ that cache, and writing credentials invalidates it immediately rather than after | --- | --- | --- | --- | | `endpoint` | `""` | `MUBIT_ENDPOINT` | Your Mubit instance URL. Required — without it there is nothing to talk to. | | `apiKey` | `""` | `MUBIT_API_KEY` | `mbt_...` key, sent as `Authorization: Bearer`. Set it with `/mubit-memory:auth`, or via plugin settings to keep it in the OS keychain. | -| `userId` | `""` | `MUBIT_CC_USER_ID` | Optional user/entity id for multi-user memory scoping. A **retrieval scope**, not a name: it is sent as `user_id`, which the server stamps on capture and then *enforces as a filter* on query. Recall does not send one, so anything captured under a `userId` is invisible to this plugin's own recall — set it only when you mean to partition memory. To label who did the work, use `actorId`. | +| `userId` | `""` | `MUBIT_CC_USER_ID` | Optional user/entity id for multi-user memory scoping. A **retrieval scope**, not a name: it is sent as `user_id`, and recall only sees memory captured under the same value. Recall does not send one, so anything captured under a `userId` is invisible to this plugin's own recall — set it only when you mean to partition memory. To label who did the work, use `actorId`. | | `actorId` | `""` (detected) | `MUBIT_CC_ACTOR_ID` | Who captured memory is attributed to. Left blank it is detected — `git config github.user`, then the local-part of `git config user.email`, then `git config user.name`, then `$USER` — and cached for 30 days at `${CLAUDE_PLUGIN_DATA}/actor.json`. Detection runs only in the detached drainer, never on a hook that a prompt is waiting on, so the first capture in a brand-new data dir may go unattributed. The value rides in each item's metadata and, unlike `userId`, never narrows what recall can see. | | `runStrategy` | `per-directory` | `MUBIT_CC_RUN_STRATEGY` | How a session maps to a Mubit run. See [Run strategies](#run-strategies). | | `capture` | `true` | `MUBIT_CC_CAPTURE` | Capture tool activity. Off means the `PostToolUse`/`Stop` hooks spool nothing. | @@ -311,19 +311,21 @@ that cache, and writing credentials invalidates it immediately rather than after | `subagentRecallTokenBudget` | `600` | `MUBIT_CC_SUBAGENT_RECALL_TOKENS` | Maximum tokens of recalled context injected into a **subagent** when it starts. `UserPromptSubmit` does not fire for a subagent, so without the `SubagentStart` hook a subagent gets no memory at all; with it, this is the ceiling. Kept below `recallTokenBudget` because a subagent's window is smaller and its task narrower, and because this is paid once per spawn — a fan-out of ten pays it ten times. Set to `0` to fall back to `recallTokenBudget`. | | `recallMaxPerSection` | `0` | `MUBIT_CC_RECALL_MAX_PER_SECTION` | Maximum items rendered per section of the injected block. `0` means no cap — the token budget and the server's own limit are what bound it. | | `recallRepeatMode` | `pointer` | `MUBIT_CC_RECALL_REPEAT_MODE` | What happens to a memory this conversation has already been shown, in the per-prompt injection and in an MCP tool result alike. `pointer` repeats it as its reference id plus its first clause — roughly 20 tokens against 200 — and keeps the id attributable, so `Stop` still reinforces it. `full` re-sends the whole entry on every prompt, which is what releases before 0.10 did. Recall injection is the plugin's largest recurring context cost: up to 1500 tokens on *every* prompt, against 356 tokens *once* for the whole MCP tool surface. Compaction resets the set, because after it the model has not seen any of it. | -| `recallAssemble` | `client` | `MUBIT_CC_RECALL_ASSEMBLE` | `client` assembles the context block locally for **0 LLM calls**. `server` uses `/v2/control/context`, which costs **2 LLM calls per prompt** and replaces the free path rather than adding to it. It also silently gives up `recallRankBy`: `/v2/control/context` has no ranking field of any kind, so on this path every recall fuses at the server's default weights and a handoff question goes back to being answered by similarity. | -| `recallFallback` | `none` | `MUBIT_CC_RECALL_FALLBACK` | What recall does when the instance has direct-access recall disabled. `none` returns nothing, for **0 LLM calls**. `agent_routed` pays **1 LLM call per prompt** to get recall anyway — typically several seconds, against a recall budget of 1500 ms, so most prompts spend the call and still inject nothing. See [When recall returns nothing](#when-recall-returns-nothing). | -| `recallRankBy` | `auto` | `MUBIT_CC_RECALL_RANK_BY` | How the server weights semantic, lexical and recency scores for a recall query. Its default weighting barely counts recency, which is why "where were we?" has always answered with the most *similar* memory rather than the most recent one — there is real event time to rank on, it was simply never asked for. `auto` decides per prompt: a temporal or handoff question ("what changed", "catch me up", "pick up where we left off", "still failing") is sent as `freshness`, which makes recency dominant, and everything else as `relevance`. Pin `relevance` to turn the rule off, `freshness` to rank every prompt by recency, or `balanced` for the middle, which the rule never chooses on its own. The exact weights belong to your instance and are operator-tunable; a query with `explain: true` reports the ones actually used. It costs **0 extra LLM calls and 0 extra round trips** — it is one field on a request that is already being sent. **`recallAssemble: server` ignores it entirely**: `/v2/control/context` has no ranking field of any kind, so rung 3 always fuses at the default weighting, silently. | -| `recallCrossRun` | `auto` | `MUBIT_CC_RECALL_CROSS_RUN` | Whether a **per-prompt** recall also asks for lessons learned in **other runs**. That half of the query has no run id to bound it, which makes it the half least able to promise an answer inside the recall budget, and it costs the same whether it finds a lesson or finds nothing. `auto` asks for it only where there is room to pay: the blocking `UserPromptSubmit` hook declines it, `recallAsync`’s detached refresh takes it — the same trade `recallAsync` already makes, without a second thing to tune. `on` asks everywhere, which is only coherent with `recallAsync` on or with `MUBIT_CC_RECALL_BUDGET_MS` raised to fund it; `off` never asks. Note that `auto` measures its slack against `MUBIT_CC_TIMEOUT_MS` too, so setting that below `3000` declines the lane on **every** path, refresh included. **This setting does not control standing lessons** — `SessionStart` fetches global-scope lessons once per session on their own route regardless of it. Costs **0 LLM calls** either way: it is one field on a request already being sent. | +| `recallAssemble` | `client` | `MUBIT_CC_RECALL_ASSEMBLE` | `client` assembles the context block locally from one retrieval request. `server` uses `/v2/control/context` instead, which is slower on every prompt and replaces the local path rather than adding to it. It also silently gives up `recallRankBy`: `/v2/control/context` takes no ranking field, so a handoff question goes back to being answered by similarity. | +| `recallFallback` | `none` | `MUBIT_CC_RECALL_FALLBACK` | What recall does when the instance has direct-access recall disabled. `none` returns nothing. `agent_routed` gets recall another way — typically several seconds per prompt, against a recall budget of 1500 ms, so most prompts wait and still inject nothing. See [When recall returns nothing](#when-recall-returns-nothing). | +| `recallRankBy` | `auto` | `MUBIT_CC_RECALL_RANK_BY` | Which ranking a recall query asks for. Without one, "where were we?" answers with the most *similar* memory rather than the most recent one. `auto` decides per prompt: a temporal or handoff question ("what changed", "catch me up", "pick up where we left off", "still failing") is sent as `freshness`, which favours recent memory, and everything else as `relevance`. Pin `relevance` to turn the rule off, `freshness` to rank every prompt by recency, or `balanced` for the middle, which the rule never chooses on its own. It adds no round trip — it is one field on a request that is already being sent. **`recallAssemble: server` ignores it entirely**: `/v2/control/context` takes no ranking field. | +| `recallCrossRun` | `auto` | `MUBIT_CC_RECALL_CROSS_RUN` | Whether a **per-prompt** recall also asks for lessons learned in **other runs**. Asking other runs makes recall slower, and it costs the same whether it finds a lesson or finds nothing. `auto` asks for it only where there is room to pay: the blocking `UserPromptSubmit` hook declines it, `recallAsync`’s detached refresh takes it — the same trade `recallAsync` already makes, without a second thing to tune. `on` asks everywhere, which is only coherent with `recallAsync` on or with `MUBIT_CC_RECALL_BUDGET_MS` raised to fund it; `off` never asks. Note that `auto` measures its slack against `MUBIT_CC_TIMEOUT_MS` too, so setting that below `3000` declines it on **every** path, refresh included. **This setting does not control standing lessons** — `SessionStart` fetches global-scope lessons once per session on their own route regardless of it. It adds no round trip either way: it is one field on a request already being sent. | | `recallAsync` | `false` | `MUBIT_CC_RECALL_ASYNC` | Never make a prompt wait on recall. On, `UserPromptSubmit` injects the block that a **detached refresh retrieved just after the previous prompt** and returns without dialling — so the hook's wall clock is a file read, however slow the endpoint is, and `MUBIT_CC_RECALL_BUDGET_MS` stops being something you have to discover and tune. It costs one turn of staleness (the block says so, in the block) and the first prompt of a session gets no recalled memory — `SessionStart`'s standing lessons still land, so the session is not memoryless. Attribution is unaffected: the ids are staged against the turn that received the block. Off by default. | -| `reflectOnEnd` | `true` | `MUBIT_CC_REFLECT_ON_END` | Reflect at `SessionEnd`. This is the only path that promotes a lesson beyond its own run, so turning it off to save a few seconds trades away cross-session memory entirely. See below. | +| `reflectOnEnd` | `true` | `MUBIT_CC_REFLECT_ON_END` | Reflect at `SessionEnd`. Lessons learned in a run reach later sessions only after this reflect, so turning it off to save a few seconds trades away cross-session memory entirely. See below. | | `sessionEndDetach` | `true` | `MUBIT_CC_SESSION_END_DETACH` | Let the end-of-session drain and reflection finish in a detached process. The host cancels the `SessionEnd` hook about a second into a teardown — under `--print` it always does — and anything still running inside the hook dies with it, including the reflect above. On, the hook stamps the marker `detached`, hands the work over and returns in milliseconds; the child reports a terminal `reflect.status` when it is done, usually a few seconds after the CLI has exited. Turn it off only where background processes are forbidden — the work then runs inline, where a teardown can cut it short. | -| `outcomeMode` | `implicit` | `MUBIT_CC_OUTCOME_MODE` | `implicit`: a turn whose reply carried the recalled memory's own vocabulary is attributed to those memories; a turn that carried none of it is recorded as `neutral` against the run and attributed to no entry, so an injection nobody used is counted rather than being invisible. `explicit`: only the model's own `mubit_outcome` calls count. `off`: no attribution, and no measurement of it either. | +| `outcomeMode` | `implicit` | `MUBIT_CC_OUTCOME_MODE` | `implicit`: each injected entry is checked on its own against the reply, and only the entries whose own vocabulary the reply carried are credited; when none were, the turn is recorded as `neutral` against the run and attributed to no entry, so an injection nobody used is counted rather than being invisible. Where there is no per-entry data (server-assembled recall, older turns) the whole turn is judged as before. If your next prompt corrects Claude ("no, that's wrong", "still failing"), a failure is posted against the entries the previous reply used. Entries Claude reported on itself with `mubit_outcome` are left to that report. `explicit`: only the model's own `mubit_outcome` calls count. `off`: no attribution, and no measurement of it either. | +| `sessionScore` | `full` (`off` under Codex) | `MUBIT_CC_SESSION_SCORE` | After each turn that showed a lesson, print a scorecard under the reply: lessons shown this session, how many replies used, and whether those turns worked, failed or are waiting on your reply. `full` is a short tree, `compact` one line, `off` nothing. It folds a local log (`scorecard/.jsonl`, 7 days) and makes no network call; nothing is shown while `capture` is off. See [the session scorecard](docs/user-guide.md#the-session-scorecard). | +| `outcomeReview` | `stop` (`nudge` under Codex) | `MUBIT_CC_OUTCOME_REVIEW` | How hard Claude is asked to credit the memory it used. Every injected memory line starts with a short id such as `[m7k2q]`, which `mubit_outcome` accepts and the plugin maps back to the entry's reference id. `nudge` adds one sentence asking Claude to credit what helped or misled it, and keeps `mubit_outcome` and `mubit_learned` loaded rather than deferred behind tool search. `stop` also has the Stop hook ask Claude once per turn to review that turn's lessons — one extra short step, which Claude Code labels "Stop hook error occurred" although nothing failed. `off` does neither. No review runs under `outcomeMode: off`. See [crediting memory by id](docs/user-guide.md#crediting-memory-by-id-the-outcome-review). | | `statusLine` | `true` | `MUBIT_CC_STATUSLINE` | Render the status line. When false it prints an empty line and exits 0 rather than erroring per frame. | | `preToolWarnings` | `false` | `MUBIT_CC_PRE_TOOL_WARNINGS` | Show the model a matching stored `rule` just before an `rm` or `git push` runs. Warnings only — it never blocks, rewrites or asks about a tool call, and the filter that decides when it runs at all is best-effort, so treat it as a reminder and use Claude Code's permission system for anything that has to hold. Off by default: this is the one setting that can put text in front of a tool call. | -| `resumeBlock` | `true` | `MUBIT_CC_RESUME_BLOCK` | Open a session with a briefing on where earlier work left off. `SessionStart` spawns a detached child that asks `/v2/control/context` for a sections block about this run, and the first substantive prompt of the session renders it above the ordinary recall block. **The one opt-in feature here that ships on**, because its cost is per *session* and not per prompt: one background process and **2 LLM calls once**, against the prompt where the model knows least about what it is walking into — nothing waits for it, and no prompt after the first pays anything. Only `startup` and `resume` sessions get one: `/clear` starts a fresh run with no history, and a compaction or a fork is already re-anchored. It renders as `` and says, in the block, that it is a briefing and not a task list. **How much it can describe depends on `runStrategy`.** `/v2/control/context` is *mostly* run-scoped — activity, working memory, rules and archived blocks all come from the run id you give it — but lessons also reach across runs, through linked runs and a session/global lesson lane. So under the default `per-directory` the block summarises everything this project has ever done; under `per-conversation`, where every session is its own run, a new session's own run is empty and the block falls back to whatever cross-run lessons apply — thinner, but not nothing. Set `MUBIT_CC_RESUME_TOKENS` to change its 1000-token ceiling. | +| `resumeBlock` | `true` | `MUBIT_CC_RESUME_BLOCK` | Open a session with a briefing on where earlier work left off. `SessionStart` spawns a detached child that asks `/v2/control/context` for a sections block about this run, and the first substantive prompt of the session renders it above the ordinary recall block. **The one opt-in feature here that ships on**, because its cost is per *session* and not per prompt: one background process and one slower request, once, against the prompt where the model knows least about what it is walking into — nothing waits for it, and no prompt after the first pays anything. Only `startup` and `resume` sessions get one: `/clear` starts a fresh run with no history, and a compaction or a fork is already re-anchored. It renders as `` and says, in the block, that it is a briefing and not a task list. **How much it can describe depends on `runStrategy`.** Under the default `per-directory` the block summarises everything this project has done; under `per-conversation`, where every session is its own run, a new session's own run is empty and the block carries only lessons learned in other sessions — thinner, but not nothing. Set `MUBIT_CC_RESUME_TOKENS` to change its 1000-token ceiling. | | `mcpTools` | `""` (the curated seven) | `MUBIT_MCP_TOOLS` | Comma-separated allowlist. A list you supply is used verbatim, not unioned with the default — that is how you ask for only `mubit_recall`. | -| `mcpLessonScope` | `session` | `MUBIT_MCP_LESSON_SCOPE` | The widest scope a lesson written by an MCP tool may claim: `run`, `session` or `global`. The default is what `mubit_learned`'s own description tells the model it does, and it is the narrowest scope from which a lesson can reach a later session at all — at `run` it cannot, because reflection stamps `run` as well and there is then no path out of the run that wrote it. Set `run` to keep every agent-written lesson inside the run that wrote it — with `runStrategy: per-directory`, that is the project it was written in. Set `global` if you want agent-written rules to follow you between projects. The ceiling only ever narrows a caller that asked for more; a write that asked for less keeps the narrower scope. | +| `mcpLessonScope` | `session` | `MUBIT_MCP_LESSON_SCOPE` | The widest scope a lesson written by an MCP tool may claim: `run`, `session` or `global`. The default is what `mubit_learned`'s own description tells the model it does, and it is the narrowest scope from which a lesson can reach a later session at all — at `run` it stays in the run that wrote it. Set `run` to keep every agent-written lesson inside the run that wrote it — with `runStrategy: per-directory`, that is the project it was written in. Set `global` if you want agent-written rules to follow you between projects. The ceiling only ever narrows a caller that asked for more; a write that asked for less keeps the narrower scope. | | `mcpResultTokenBudget` | `2000` | `MUBIT_CC_MCP_RESULT_TOKENS` | The most one Mubit MCP tool result may put in front of the model. A lesson list or a recall always comes back one line per item with the id kept, and a memory already shown in this run is repeated as its reference id plus its first clause; anything over the ceiling is cut, and the untouched result is saved under the plugin data directory, where the note at the foot of the result names it. `0` returns the raw result. | | `pins` | `true` | `MUBIT_CC_PINS` | Put the constraints pinned with `/mubit-memory:pin` in front of the model on every prompt of the run. A pin is a sentence that is true for *this task* — "don't touch the vendored server", "no new dependencies until this PR lands" — and before this existed the only place to put one was memory, where it became a durable lesson and was recalled into every later session of a project where it had stopped being true. Pins render above the recalled block and, unlike recall, on the prompts recall skips: a two-word answer, an open circuit breaker, a recall that failed or found nothing. Capped at five pins, 200 characters each and 240 rendered tokens — tight, because a pin is unranked and never degrades to a pointer, so it is the most expensive context the plugin injects per unit of information. It costs **0 extra requests on the prompt path**: the hook reads one file, and the refresh rides in the detached drainer. Counted separately as `recall.pin_tokens`, so `recall.tokens` keeps meaning what recall cost. Off makes the feature invisible — the injected block is byte-for-byte what it was without it. | @@ -356,15 +358,14 @@ camelCase name in parentheses. ### When recall returns nothing -Recall's default path is the **direct bypass**: one request, no LLM calls, tens to a couple of -hundred milliseconds server-side. That path is gated by your instance's direct-access policy. -When an operator has it switched off, the request comes back `403`, and the plugin has a -choice: return nothing, or pay a router LLM call to get an answer another way. +Recall's default path is the **direct bypass**: one quick request. That path is gated by your +instance's direct-access policy. When an operator has it switched off, the request comes back +`403`, and the plugin has a choice: return nothing, or ask again through the slower +`agent_routed` path. -It returns nothing, and says so. The alternative costs a language-model call in front of every -prompt you type — measured at a ~5 s median with a tail past 11 s, against a recall budget of -1500 ms inside a 3 s hook timeout. Most of those prompts spend the call and inject nothing -anyway, so the default trades away recall you were mostly not receiving for latency you were +It returns nothing, and says so. The alternative puts seconds in front of every prompt you +type, against a recall budget of 1500 ms inside a 3 s hook timeout. Most of those prompts would +wait and inject nothing anyway, so the default trades away recall you were mostly not receiving for latency you were always paying. `MUBIT_CC_RECALL_FALLBACK=agent_routed` opts back in. **The real fix is on the instance, not here.** Ask whoever operates it to enable direct-access @@ -477,9 +478,8 @@ zero network — it rides the ordinary drain, redaction and circuit breaker incl the **parent's** run id, because a handoff is scoped to a run and a subagent's sub-run id never reaches the wire. There is no way to address a note to another run. -"Open" is computed by the command, not by the instance: the instance never flips a handoff's -`active` flag and has no list route, so the command reads both entry types for the run and -joins them — open means no feedback names that id. The resume briefing a new session gets +"Open" is computed by the command: it reads both entry types for the run and joins them — open +means no feedback names that id. The resume briefing a new session gets includes the open handoffs, so work handed back and never reviewed is the first thing the next session hears about. @@ -489,16 +489,14 @@ A different symptom, with a different fix. If the status line shows `◌ not_res `recall.empty_reason` is blank rather than `policy_denied`, the instance is answering — just not inside the budget. The call is abandoned after it has already been paid for. -**This is not only a self-hosting problem.** Measured 2026-08-24, a rung-1 query against -*hosted* Mubit took 2.0-2.6 s, against a `MUBIT_CC_RECALL_BUDGET_MS` of 1500. Self-hosted -instances land in a similar range. On either, an ordinary session recalls nothing on every +**This can happen against any instance.** When a query takes longer than +`MUBIT_CC_RECALL_BUDGET_MS` (1500 ms by default), an ordinary session recalls nothing on every prompt and reports it as zeros. **The tell is the number, not the zero.** `mubit-inspect` printing `ms: 1507` — a figure sitting on the budget — is a timeout. A genuinely empty result returns fast and carries an -`empty_reason`. `status/health.json` will still read `ok: true, state: ready`, because -`/v2/core/health` is fast and the query path is not, so a healthy connection glyph does not -clear this. +`empty_reason`. `status/health.json` can still read `ok: true, state: ready` — the health check is a +separate, quicker call — so a healthy connection glyph does not clear this. **Raising the budget mostly cannot fix it.** The hook's hard stop is `min(recallBudgetMs + 400, 2800)` — capped at 2800 ms, because `UserPromptSubmit` has a 3 s @@ -522,16 +520,12 @@ block, so `Stop` reinforces exactly the memories the model actually had. ### Turning off `reflectOnEnd` -Mubit extracts lessons on its own as it ingests, but those keep the scope they were extracted -at — typically `run` — and a `run`-scoped lesson is invisible to your next session. +Lessons learned in a run reach later sessions only after `POST /v2/control/reflect`, which +`SessionEnd` issues and `reflectOnEnd` controls. Turn it off and your store still fills up — it +just never produces anything a future session can see. It is not a latency knob. -`POST /v2/control/reflect`, which `SessionEnd` issues and which `reflectOnEnd` controls, is the -only thing in the system that can widen a lesson's scope. Turn it off and your store still -fills up — it just never produces anything a future session can see. It is not a latency knob. - -(Reflecting is necessary, not sufficient. Rules are never scope-promoted, since they are -enforced as written, and anything else has to establish itself before it travels. Expect -widening over several sessions, not on the first reflect.) +(Reflecting is necessary, not sufficient: expect a lesson to reach later sessions over several +sessions, not on the first reflect.) ### Run strategies @@ -626,7 +620,7 @@ of three escalates, and only ever to `not_responding` — never to `unreachable` | Status line never appears at all | A plugin cannot register `statusLine`; the shipped entry is inert | Add it to your own `~/.claude/settings.json` — see [A status line](#a-status-line) | | `/mcp` lists 21 tools instead of ten | You are on 0.9.1 or older, whose bundled MCP server predates the allowlist patch and registers everything | Upgrade. On an older version it is not cosmetic: every session pays for all 21 tool schemas | | A saved lesson never becomes visible in another project | `mubit_learned` writes at the `mcpLessonScope` ceiling, `session` by default. A `session` lesson reaches later sessions but does not follow you between projects | Raise `mcpLessonScope` to `global`, or keep `reflectOnEnd` on and run `/mubit-memory:reflect` at meaningful checkpoints | -| A just-saved memory is not findable a second later | `mubit_learned` returns when the write is **queued**, not stored. Embedding and indexing happen after the call returns | Wait. Reflecting or searching immediately honestly returns nothing, and that is not a fault | +| A just-saved memory is not findable a second later | `mubit_learned` returns when the write is **queued**, not stored. It becomes searchable a little after the call returns | Wait. Reflecting or searching immediately honestly returns nothing, and that is not a fault | | Hook captures and `/mubit-memory:remember` writes land in different runs | `runStrategy: per-conversation` | Use `per-directory` | | `Config error: MUBIT_CC_RUN_STRATEGY=static requires MUBIT_CC_RUN_ID` | `static` with no pin | Set `MUBIT_CC_RUN_ID`, or pick another strategy | | Edits to the plugin have no effect | Marketplace installs are copied into `~/.claude/plugins/cache` | Iterate with `claude --plugin-dir ` | diff --git a/integrations/claude-code/THIRD_PARTY_NOTICES.md b/integrations/claude-code/THIRD_PARTY_NOTICES.md index 9dc50e7..e792b1b 100644 --- a/integrations/claude-code/THIRD_PARTY_NOTICES.md +++ b/integrations/claude-code/THIRD_PARTY_NOTICES.md @@ -20,8 +20,8 @@ Most of the bundle is our own code (`@mubit-ai/mcp`, `@mubit-ai/sdk`), covered b | `long` | Apache-2.0 | Copyright 2016 Daniel Wirtz | | | `lodash.camelcase` | MIT | Copyright John-David Dalton (Lodash) | | -Versions are resolved at build time by the source repository's `mcp/package-lock.json` -(this published tree does not carry the lockfile); `@grpc/grpc-js` also embeds its version +Versions are resolved when the vendored server is built (this tree does not carry its +lockfile); `@grpc/grpc-js` also embeds its version string in the bundle, so `grep 'grpc-node-js/' mcp/dist/server.js` reads back what shipped. 1. The TypeScript SDK's upstream `LICENSE` is transitional: code contributed before the diff --git a/integrations/claude-code/bin/activity.mjs b/integrations/claude-code/bin/activity.mjs index 0cbc132..8a5435f 100644 --- a/integrations/claude-code/bin/activity.mjs +++ b/integrations/claude-code/bin/activity.mjs @@ -424,7 +424,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -544,6 +544,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -617,6 +627,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1026,7 +1038,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1093,7 +1105,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1399,7 +1411,7 @@ async function exportActivity(cfg, params2 = {}) { return fail( 400, "bad_request", - "export requires a run id: the route takes no limit, and an empty run_id means every run this key can see \u2014 which is an unbounded response read into one string" + "export requires a run id: the route takes no limit, so an unscoped export is an unbounded response read into one string" ); } const req = { @@ -1481,8 +1493,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } diff --git a/integrations/claude-code/bin/admin.mjs b/integrations/claude-code/bin/admin.mjs index 3aca8dd..f20e4e4 100755 --- a/integrations/claude-code/bin/admin.mjs +++ b/integrations/claude-code/bin/admin.mjs @@ -429,7 +429,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -549,6 +549,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -622,6 +632,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1031,7 +1043,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1104,7 +1116,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1195,7 +1207,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1578,8 +1590,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -1774,6 +1786,13 @@ function clamp2(v, lo, hi, dflt) { return Math.min(hi, Math.max(lo, Math.trunc(n))); } +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ "mental_models", @@ -1947,6 +1966,10 @@ function str5(v) { return typeof v === "string" ? v.trim() : ""; } +// lib/scorecard-log.mjs +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; + // mcp/src/egress.mjs function selectLessons(rows, o) { const mine = (r) => o.runId !== "" && (r.runId === o.runId || r.sourceRunId === o.runId); diff --git a/integrations/claude-code/bin/dashboard.mjs b/integrations/claude-code/bin/dashboard.mjs index 40dbbba..1015e85 100644 --- a/integrations/claude-code/bin/dashboard.mjs +++ b/integrations/claude-code/bin/dashboard.mjs @@ -444,7 +444,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -564,6 +564,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -637,6 +647,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1046,7 +1058,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1116,7 +1128,7 @@ async function postOutcome(cfg, req, opts = {}) { req, "reference_id", "postOutcome", - 'pass "global" for run-level attribution and put the real ids in entry_ids[] (\xA71.3)' + 'pass "global" for run-level attribution and put the real ids in entry_ids[]' ) ); if (bad) return refuse(cfg, started, bad, { route: ROUTES.outcome }); @@ -1139,7 +1151,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1230,13 +1242,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -1846,8 +1858,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -2081,15 +2093,50 @@ var API_ERROR_KEY = "api_error"; function decideOutcome(turn) { if (!isObject(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str4(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str4(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str4(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str4(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -2099,21 +2146,44 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str4(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + } + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str4(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject(v)) out[ref] = /** @type {any} */ + v; + } + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -2181,7 +2251,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -3667,8 +3737,8 @@ async function getRoute(ctx, res, path, url) { } if (path === "/api/lessons") { const payload = await lessonsPayload(cfg, { - // An empty `run` means every run, and that is the only spelling it gets. A second - // `allRuns` parameter would just be a second way to pin this tab back to one run, which + // An empty `run` is how this tab asks for lessons from all runs, and that is the only + // spelling it gets. A second `allRuns` parameter would just be a second way to pin this tab back to one run, which // is the bug that made a global lesson from another run structurally invisible. run: String(url.searchParams.get("run") ?? ""), // A rendering context, never a filter: it is what `fromOtherRun` is measured against. diff --git a/integrations/claude-code/bin/handoff.mjs b/integrations/claude-code/bin/handoff.mjs index 58906bc..255d129 100644 --- a/integrations/claude-code/bin/handoff.mjs +++ b/integrations/claude-code/bin/handoff.mjs @@ -182,7 +182,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -302,6 +302,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -375,6 +385,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1030,7 +1042,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1136,7 +1148,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1227,7 +1239,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1474,8 +1486,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } diff --git a/integrations/claude-code/bin/impl/statusline.mjs b/integrations/claude-code/bin/impl/statusline.mjs index 4d0f801..7173989 100644 --- a/integrations/claude-code/bin/impl/statusline.mjs +++ b/integrations/claude-code/bin/impl/statusline.mjs @@ -276,7 +276,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -382,6 +382,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -455,6 +465,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -594,7 +606,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -768,7 +780,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; diff --git a/integrations/claude-code/bin/import.mjs b/integrations/claude-code/bin/import.mjs index 012666c..c7d626a 100644 --- a/integrations/claude-code/bin/import.mjs +++ b/integrations/claude-code/bin/import.mjs @@ -178,14 +178,31 @@ function posInt(v, d) { // lib/codex-rollout.mjs var TAIL_BYTES = 512 * 1024; var HEAD_BYTES = 256 * 1024; -var INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|turn_aborted|user_shell_command|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; -function isInjectedUserText(text) { - return typeof text === "string" && INJECTED_USER_RE.test(text); -} -function stripInjectedBlocks(content) { - if (typeof content === "string") return isInjectedUserText(content) ? "" : content; +var INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; +var USER_ACTION_RE = /^\s*<(?:turn_aborted|user_shell_command)\b/; +var HOOK_PROMPT_OPEN_RE = /\s*]*)?>/y; +var HOOK_PROMPT_CLOSE = ""; +function isHookFeedback(text) { + const end = text.trimEnd().length; + let at = 0; + do { + HOOK_PROMPT_OPEN_RE.lastIndex = at; + if (!HOOK_PROMPT_OPEN_RE.test(text)) return false; + const close = text.indexOf(HOOK_PROMPT_CLOSE, HOOK_PROMPT_OPEN_RE.lastIndex); + if (close === -1) return false; + at = close + HOOK_PROMPT_CLOSE.length; + } while (at < end); + return true; +} +function isInjectedUserText(text, opts) { + if (typeof text !== "string") return false; + if (INJECTED_USER_RE.test(text) || isHookFeedback(text)) return true; + return !opts?.keepUserActions && USER_ACTION_RE.test(text); +} +function stripInjectedBlocks(content, opts) { + if (typeof content === "string") return isInjectedUserText(content, opts) ? "" : content; if (!Array.isArray(content)) return content; - return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text))); + return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text, opts))); } var NOT_FOUND = Object.freeze({ found: false, @@ -214,7 +231,7 @@ var TOOL_TABLE = { Write: ["trace", "medium"], MultiEdit: ["trace", "medium"], NotebookEdit: ["trace", "medium"], - // Shell — also subject to §4.4 self-reference suppression, which runs upstream of this + // Shell — also subject to self-reference suppression, which runs upstream of this // module in `capture.mjs` step 2. Bash: ["tool_output", "low"], BashOutput: ["tool_output", "low"], @@ -492,7 +509,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; var MAX_ENV_TAGS = 8; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); @@ -676,6 +693,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -749,6 +776,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1762,7 +1791,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1838,7 +1867,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1929,7 +1958,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireItems(req) { const items = req && typeof req === "object" ? req.items : void 0; @@ -2166,7 +2195,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; diff --git a/integrations/claude-code/bin/pin.mjs b/integrations/claude-code/bin/pin.mjs index e4452ab..5cc41df 100644 --- a/integrations/claude-code/bin/pin.mjs +++ b/integrations/claude-code/bin/pin.mjs @@ -192,7 +192,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -312,6 +312,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -385,6 +395,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -518,6 +530,13 @@ function safeCwd() { // lib/pins.mjs import { join as join6 } from "node:path"; +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ "mental_models", @@ -1109,7 +1128,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1176,7 +1195,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; diff --git a/integrations/claude-code/docs/user-guide.md b/integrations/claude-code/docs/user-guide.md index 37f9da4..2ffb127 100644 --- a/integrations/claude-code/docs/user-guide.md +++ b/integrations/claude-code/docs/user-guide.md @@ -16,11 +16,11 @@ Read these now and you will skip the three most common support questions. 1. **After installing, you must start a *new* session.** `/reload-plugins` loads the code but does not fire the `SessionStart` hook, so there is no run id, no status line, and nothing on disk. It looks broken. It is not. -2. **An accepted write is not yet a stored memory.** Ingest returns `queued` and indexing - finishes a moment later, so a recall issued immediately after a capture can legitimately come +2. **An accepted write is not yet a stored memory.** Ingest returns `queued` and the write + lands a moment later, so a recall issued immediately after a capture can legitimately come back empty. `/mubit-memory:doctor` shows you the job states. -3. **Memory becomes cross-session only at `SessionEnd`.** That is the one path that promotes a - lesson beyond the run it was learned in. Kill the terminal and you lose the promotion. +3. **Memory becomes cross-session only at `SessionEnd`.** Lessons learned in a run reach later + sessions only after the reflect at session end. Kill the terminal and that reflect never runs. --- @@ -240,10 +240,10 @@ What happens on its own: | --- | --- | | Session starts | Derives a run id from your directory, registers the agent, pulls up to 5 standing (`global`) lessons, and tells the model memory is active | | You `cd` into another repo | Moves the session to that repo's run, and flushes what the run you left had spooled. A `cd` inside one repo changes nothing | -| Every prompt you send | Queries memory and injects what is relevant, within a 1500 ms budget and a 1500-token cap. **Zero LLM calls** — assembly is local | +| Every prompt you send | Queries memory and injects what is relevant, within a 1500 ms budget and a 1500-token cap. Assembled locally | | Every tool call | Redacts and spools it. Zero network on the hot path | | Every tool failure | Captured — these produce the most useful lessons | -| Every turn ends | Writes the `Q: … / A: …` pair, flushes the spool, and attributes the turn's success or failure back to the memories that were injected for it | +| Every turn ends | Writes the `Q: … / A: …` pair, flushes the spool, credits the memories the reply actually used, and — on turns that showed a lesson — asks Claude to review those lessons and prints the [session scorecard](#the-session-scorecard) | | Before a compact | Snapshots the last 200 KB of transcript before the host throws it away | | Session ends | Drains, flushes outcomes, then reflects — extracting lessons that outlive this run | @@ -253,13 +253,13 @@ costs you a memory, never a turn. ### The status line ``` -● mubit: cc-my-project-9f2a11c4 · local · recall 6/1.2k tok · saved 12t/1q · lessons 3g +● mubit: cc-my-project- · local · recall 6/1.2k tok · saved 12t/1q · lessons 3g ``` | Field | Meaning | | --- | --- | | `●` | Connection state — see [Part 8](#part-8--when-it-looks-broken) | -| `cc-my-project-9f2a11c4` | Your run id — the memory scope this session writes to | +| `cc-my-project-` | Your run id — the memory scope this session writes to | | `local` | Derived mode | | `recall 6/1.2k tok` | 6 memories injected this turn, costing 1.2k tokens | | `saved 12t/1q` | 12 tool calls and 1 turn captured | @@ -268,6 +268,106 @@ costs you a memory, never a turn. Groups are hidden while still zero. It reads two local JSON files and never touches the network, so a dead server can never freeze your terminal. +### The session scorecard + +After each turn that showed Claude at least one lesson, a short card appears under the reply. +Claude Code prefixes it with `Stop says:`. + +``` +mubit · this session · lessons on 6 of 8 prompts · +2 learned · memory added 6.4k tok + 9 lessons shown + ├ 5 used 3 worked · 1 failed · 1 waiting on your reply + └ 4 not used + this turn: used "run vitest with --pool=forks" + review: "use npm ci in CI" failed on prompt 5 · 2 lessons shown 3+ times and never used +``` + +The header counts the prompts in this session that carried a lesson, the lessons Claude saved +with `mubit_learned`, and the tokens memory added to the context (standing lessons plus every +per-prompt injection). Parts that would read zero are left out. + +The tree counts **lessons**: distinct entries of type `lesson` shown in this session. Rules, +facts and past work are injected too, but they are not counted here. Every lesson lands in +exactly one row, so the rows always add up to the total. + +| Row | Meaning | +| --- | --- | +| shown | Injected in full, or repeated as a `(seen earlier)` pointer. Standing lessons count on the first prompt after each session start. | +| used | The lesson's own distinctive words appear in Claude's reply, or Claude named it in `mubit_outcome`. Words shared with another entry shown that turn, or that you typed in your prompt, do not count. A lesson needs at least two of its words, or a quarter of them if that is more; when it has two or fewer, it needs all of them. | +| unknown | Could not be checked: the lesson had no distinctive words, there was no reply, the API failed, or you interrupted the turn. Shown only when non-zero. | +| not used | Everything else. This is not a penalty: a lesson like "never force-push" is followed by *not* doing something, which a word match cannot see. The outcome review below is what catches those. | + +A used lesson gets a verdict for the turn it was used in. The first rule that applies wins: + +1. **Claude's own verdict** through `mubit_outcome`: success or partial means worked, failure + means failed. A `neutral` verdict counts the lesson as used and leaves the verdict to the + rules below. +2. **Your next prompt corrects Claude** — "no, that's wrong", "still failing", "revert + that" — means failed. A correction applies to your last prompt before it that was not a + slash command; if you interrupted that turn, nothing is failed. It never counts across + `/clear` or for a slash command, and a bare "no" answering a question Claude asked is an + answer, not a correction. +3. **The turn's last tool call that changed something failed** means failed. Reads and + searches do not count, so a `grep` that matched nothing is not a failure. +4. **You sent another prompt** means worked. +5. Otherwise the lesson is **waiting on your reply**. + +A lesson used on several turns shows as failed if any of them failed, then waiting if any is +still waiting, and worked otherwise. `this turn` names up to two lessons this reply used. The +`review` line appears only when something failed, or when a lesson has been shown three or more +times and never used — a candidate for `/mubit-memory:forget`. + +The card is built from a local log (`scorecard/.jsonl` under the plugin data +directory, kept for 7 days) and costs no network call. Set `sessionScore` to `compact` for a +single line or `off` to hide it. Nothing is measured, and no card is shown, while `capture` is +off. + +### Crediting memory by id: the outcome review + +Mubit learns which memories help from outcomes. The plugin posts one automatically at the end +of every turn, but the model's own judgement — "this lesson helped", "this one was wrong" — +is the stronger signal, and it used to be almost impossible for Claude to give: memory arrived +as plain lines with no id to name. + +Now every injected memory line starts with a short id: + +``` +## Lessons +- [m7k2q] run vitest with --pool=forks; the thread pool hangs on the native module +- (seen earlier) [m3jd9] — use npm ci in CI… +``` + +Claude passes those ids to `mubit_outcome`, and the plugin maps each one back to the entry's +real reference id before the call leaves your machine. `outcomeReview` decides how hard Claude +is asked to do it: + +| Value | What happens | +| --- | --- | +| `stop` (default in Claude Code) | Everything `nudge` does, plus: when a turn showed lessons that were new to Claude or that its reply appeared to use, the Stop hook asks Claude once to review them. Claude credits the ones that helped (`success`), flags the ones that misled it (`failure`), saves a corrected lesson with `mubit_learned` when one was wrong, and ends with a one-line `Memory review:` summary. | +| `nudge` (default in Codex) | One sentence in the memory block asks Claude to credit what helped or misled it before finishing. In Claude Code, `mubit_outcome` and `mubit_learned` are also kept loaded rather than deferred behind tool search, so reporting costs one call instead of two. | +| `off` | No sentence, no review, and the two tools are deferred like the rest. The ids stay on the lines. | + +The review costs one extra short model step on the turns that trigger it. Claude Code labels any +continuation a Stop hook asks for as **"Stop hook error occurred"**, even though nothing failed +— that label is the review running. Set `outcomeReview` to `nudge` if you would rather not see +it. With `outcomeMode: off` there is no review at all. Codex defaults to `nudge` because a Stop +continuation has not been verified there. + +A message you send while the review is running is answered in the same continuation; that +answer is stored and measured like any other. If the review itself hits an API error, the +answer it followed still gets its credit. + +What reaches the server: + +- The automatic outcome now reinforces only the entries the reply actually used, instead of + everything that was injected that turn. +- An entry Claude credited or blamed itself is left to its verdict, so it is never counted + twice for one turn. +- When your next prompt corrects Claude, a failure (−0.3) is posted against the entries the + previous reply used, unless that turn already failed on a tool call. A correction that could + not be sent right away is retried when the session ends, and a turn that was corrected before + its automatic outcome went out gets no credit afterwards. + --- ## Part 4 — Prove it is actually working @@ -286,7 +386,7 @@ is why `state` is `unreachable` and `last_error` is set: ```json { - "run_id": "cc-my-project-9f2a11c4", + "run_id": "cc-my-project-", "mode": "hosted", "state": "unreachable", "captured": { "tools": 0, "turns": 0, "pending": 1 }, @@ -575,7 +675,7 @@ sizes only, never content. ### Keep this on -`reflectOnEnd`, default `true`. It is the only path that promotes a lesson beyond its own run. +`reflectOnEnd`, default `true`. Lessons learned in a run reach later sessions only through it. Turning it off to save a few seconds at exit trades away cross-session memory entirely. ### How lessons from other sessions reach you @@ -587,11 +687,11 @@ knowing before you conclude that cross-session memory is not working. | Path | Default | What it costs | | --- | --- | --- | | The **session-start** standing set — up to 5, in the opening context | **on** | one request, inside the 900 ms slice of session start | -| The **per-prompt** cross-run lane — lessons from other runs alongside this turn's recall | declines | it is the most expensive part of a recall, and at the shipped budget there is no room for it | -| The **detached refresh** that would pay for that lane out of band | off | a second process on every prompt | +| The **per-prompt** cross-run request — lessons from other runs alongside this turn's recall | declines | it makes recall slower, and at the shipped budget there is no room for it | +| The **detached refresh** that would fetch them out of band | off | a second process on every prompt | The second one is `recallCrossRun`, default `auto`. `auto` reads the budget the caller already -passed and spends the lane only where there is room; at the shipped `recallBudgetMs` of +passed and asks for them only where there is room; at the shipped `recallBudgetMs` of `1500` there is not, so it declines every time. Two ways to change that, and they are a pair: ```bash @@ -652,12 +752,24 @@ on it — the plugin denies nothing at any exit it controls, and it exits 0 on e including the path where its own internal deadline fires — but if you write your own pre-tool hook, do not carry the assumption forward. +### The scorecard and the outcome review + +`sessionScore`, default **`full`** (`off` in Codex): `full` prints the card described in +[The session scorecard](#the-session-scorecard) under every reply that showed a lesson, +`compact` prints a one-line summary instead, `off` prints nothing. + +`outcomeReview`, default **`stop`** (`nudge` in Codex): how hard Claude is asked to credit the +memory it used. See [Crediting memory by id](#crediting-memory-by-id-the-outcome-review) for +what each value does and what the review costs. + ### Quieting it temporarily ```bash MUBIT_CC_CAPTURE=0 claude # stop capturing, keep recall MUBIT_CC_RECALL=0 claude # stop injecting, keep capturing MUBIT_CC_PRE_TOOL_WARNINGS=0 claude # stop the pre-command reminders (already the default) +MUBIT_CC_SESSION_SCORE=off claude # hide the scorecard under each reply +MUBIT_CC_OUTCOME_REVIEW=nudge claude # no end-of-turn review step, keep the one-line ask ``` ### Fewer MCP tools @@ -808,5 +920,5 @@ shape of the on-disk status marker. Every expected-output block above is a trans Not verified: a fresh clone-and-install from GitHub. The transcripts above were produced from a local directory marketplace, so the install path most people take — `/plugin marketplace add mubit-ai/plugins` — is exercised by its parts and not end to end here. Also unverified is -any behaviour that needs a running Mubit: recall content, reflection output, and lesson -promotion — those need a live instance and are covered separately. +any behaviour that needs a running Mubit: recall content, reflection output, and lessons +reaching later sessions — those need a live instance and are covered separately. diff --git a/integrations/claude-code/hooks/dist/impl/capture.mjs b/integrations/claude-code/hooks/dist/impl/capture.mjs index f0f4bd8..7673e30 100755 --- a/integrations/claude-code/hooks/dist/impl/capture.mjs +++ b/integrations/claude-code/hooks/dist/impl/capture.mjs @@ -2,7 +2,7 @@ // mubit-memory — generated by esbuild. Do not edit; edit hooks/src or lib. // hooks/src/capture.mjs -import { join as join12 } from "node:path"; +import { join as join13 } from "node:path"; // lib/actor.mjs import { dirname as dirname2, join as join2, resolve } from "node:path"; @@ -239,7 +239,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE2 = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION2 = 3; +var CACHE_VERSION2 = 4; var MAX_ENV_TAGS = 8; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); @@ -409,6 +409,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -482,6 +492,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -650,14 +662,31 @@ function str2(v) { var TAIL_BYTES = 512 * 1024; var HEAD_BYTES = 256 * 1024; var MAX_PROMPT_CHARS = 4096; -var INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|turn_aborted|user_shell_command|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; -function isInjectedUserText(text) { - return typeof text === "string" && INJECTED_USER_RE.test(text); -} -function stripInjectedBlocks(content) { - if (typeof content === "string") return isInjectedUserText(content) ? "" : content; +var INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; +var USER_ACTION_RE = /^\s*<(?:turn_aborted|user_shell_command)\b/; +var HOOK_PROMPT_OPEN_RE = /\s*]*)?>/y; +var HOOK_PROMPT_CLOSE = ""; +function isHookFeedback(text) { + const end = text.trimEnd().length; + let at = 0; + do { + HOOK_PROMPT_OPEN_RE.lastIndex = at; + if (!HOOK_PROMPT_OPEN_RE.test(text)) return false; + const close = text.indexOf(HOOK_PROMPT_CLOSE, HOOK_PROMPT_OPEN_RE.lastIndex); + if (close === -1) return false; + at = close + HOOK_PROMPT_CLOSE.length; + } while (at < end); + return true; +} +function isInjectedUserText(text, opts) { + if (typeof text !== "string") return false; + if (INJECTED_USER_RE.test(text) || isHookFeedback(text)) return true; + return !opts?.keepUserActions && USER_ACTION_RE.test(text); +} +function stripInjectedBlocks(content, opts) { + if (typeof content === "string") return isInjectedUserText(content, opts) ? "" : content; if (!Array.isArray(content)) return content; - return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text))); + return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text, opts))); } var NOT_FOUND = Object.freeze({ found: false, @@ -688,7 +717,7 @@ function toolCallRecord(transcriptPath, toolUseId, opts = {}) { const exitCode = Number.isFinite(item2.exit_code) ? Number(item2.exit_code) : null; return { found: true, - // § `status` is the host's own verdict and is what to trust. `exit_code` is the + // `status` is the host's own verdict and is what to trust. `exit_code` is the // fallback for a record shape that carries one without the other. failed: status ? status === "failed" : exitCode !== null && exitCode !== 0, exitCode, @@ -898,9 +927,9 @@ function scrub(text, count) { } function entropy(s) { if (s === null || s === void 0) return 0; - const str6 = typeof s === "string" ? s : String(s); - if (str6.length === 0) return 0; - const buf = Buffer.from(str6, "utf8"); + const str8 = typeof s === "string" ? s : String(s); + if (str8.length === 0) return 0; + const buf = Buffer.from(str8, "utf8"); const n = buf.length; if (n === 0) return 0; const counts = new Uint32Array(256); @@ -1629,7 +1658,7 @@ var TOOL_TABLE = { Write: ["trace", "medium"], MultiEdit: ["trace", "medium"], NotebookEdit: ["trace", "medium"], - // Shell — also subject to §4.4 self-reference suppression, which runs upstream of this + // Shell — also subject to self-reference suppression, which runs upstream of this // module in `capture.mjs` step 2. Bash: ["tool_output", "low"], BashOutput: ["tool_output", "low"], @@ -1725,6 +1754,114 @@ function classifyTurn(prompt, lastAssistantMessage, opts = {}) { agentType: isSubagent && typeof o.agent_type === "string" ? o.agent_type : "" }; } +var READ_TOOLS = /* @__PURE__ */ new Set([ + "Read", + "NotebookRead", + "WebFetch", + "LSP", + "TaskOutput", + "BashOutput", + "ToolSearch", + "ListMcpResourcesTool", + "ReadMcpResourceTool", + "view_image" +]); +var SEARCH_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob", "WebSearch", "web_search"]); +var WRITE_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"]); +var SHELL_TOOLS = /* @__PURE__ */ new Set(["Bash", "shell", "exec_command", "write_stdin", "KillShell"]); +var OWN_MCP_PREFIXES2 = ["mcp__plugin_mubit-memory_mubit__", "mcp__mubit__"]; +var OWN_READ_TOOLS = /* @__PURE__ */ new Set([ + "mubit_recall", + "mubit_dereference", + "mubit_status", + "mubit_memory_health", + "mubit_diagnose" +]); +var SEARCH_VERBS = /* @__PURE__ */ new Set(["grep", "rg", "ag", "ack", "find", "fd"]); +var READ_VERBS = /* @__PURE__ */ new Set([ + "ls", + "cat", + "head", + "tail", + "less", + "wc", + "stat", + "file", + "which", + "type", + "echo", + "printf", + "pwd", + "tree", + "du", + "df", + "diff", + "cmp", + "test", + "[", + "jq", + "sort", + "uniq", + "cut", + "tr", + "nl", + "sed", + "awk" +]); +var READ_GIT = /* @__PURE__ */ new Set(["status", "diff", "log", "show", "grep", "rev-parse", "branch", "ls-files", "blame"]); +var MAX_SHELL_PARSE = 4096; +function toolIntent(toolName, toolInput) { + try { + const name = typeof toolName === "string" ? toolName.trim() : ""; + if (!name) return "other"; + if (READ_TOOLS.has(name)) return "read"; + if (SEARCH_TOOLS.has(name)) return "search"; + if (WRITE_TOOLS.has(name)) return "write"; + if (SHELL_TOOLS.has(name)) return shellIntent(toolInput); + const own = OWN_MCP_PREFIXES2.find((p) => name.startsWith(p)); + if (own && OWN_READ_TOOLS.has(name.slice(own.length))) return "read"; + return "other"; + } catch { + return "other"; + } +} +function shellIntent(input) { + const raw = input && typeof input === "object" ? input.command : void 0; + const cmd = typeof raw === "string" ? raw : Array.isArray(raw) ? raw.filter((s) => typeof s === "string").join(" ") : ""; + if (!cmd.trim() || cmd.length > MAX_SHELL_PARSE) return "exec"; + const cleaned = cmd.replace(/&>\s*\/dev\/null/g, " ").replace(/\d*>>?\s*\/dev\/null/g, " ").replace(/\d*>&\d+/g, " "); + if (/[<>]/.test(cleaned.replace(/<< t === "--in-place" || /^-[a-zA-Z]*i/.test(t))) return "exec"; + if (!READ_VERBS.has(verb)) return "exec"; + } + return search ? "search" : "read"; +} +function gitSubcommand(tokens) { + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t === "-C" || t === "-c") { + i++; + continue; + } + if (t.startsWith("-")) continue; + return t; + } + return ""; +} // lib/ledger.mjs import { @@ -1752,15 +1889,50 @@ var API_ERROR_KEY = "api_error"; function decideOutcome(turn) { if (!isObject4(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str3(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str3(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject4(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str3(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str3(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -1770,21 +1942,44 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str3(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str3(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; + } + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject4(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject4(v)) out[ref] = /** @type {any} */ + v; + } + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject4(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -2112,11 +2307,603 @@ function numOr2(v, d) { return Number.isFinite(n) ? n : d; } +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function resolveHandles(ids, knownRefs) { + const byHandle = /* @__PURE__ */ new Map(); + for (const ref of Array.isArray(knownRefs) ? knownRefs : []) { + const h = handleFor(ref); + if (h) byHandle.set(h, ref); + } + const out = []; + const unresolved = []; + for (const raw of Array.isArray(ids) ? ids : []) { + if (typeof raw !== "string") continue; + const bare = bareOf(raw); + if (BARE_RE.test(bare)) { + const ref = byHandle.get(bare); + if (ref) out.push(ref); + else { + out.push(bare); + unresolved.push(bare); + } + } else if (raw.trim()) { + out.push(raw.trim()); + } + } + return { ids: out, unresolved }; +} +function knownRefsFromRows(rows) { + const last = /* @__PURE__ */ new Map(); + let n = 0; + const note = (ref) => { + if (typeof ref === "string" && ref.trim()) last.set(ref.trim(), n++); + }; + for (const row of Array.isArray(rows) ? rows : []) { + if (!row || typeof row !== "object") continue; + if (row.kind !== "start" && row.kind !== "shown" && row.kind !== "refs") continue; + if (row.lessons && typeof row.lessons === "object") for (const ref of Object.keys(row.lessons)) note(ref); + if (Array.isArray(row.refs)) for (const ref of row.refs) note(ref); + } + return [...last.entries()].sort((a, b) => a[1] - b[1]).map(([ref]) => ref); +} +function bareOf(v) { + const s = typeof v === "string" ? v.trim() : ""; + return s.startsWith("[") && s.endsWith("]") ? s.slice(1, -1).trim() : s; +} + +// lib/review.mjs +var DEFAULT_MAX = 6; +var VERDICTS = /* @__PURE__ */ new Set(["success", "failure", "partial", "neutral"]); +var MAX_TITLE_CHARS = 64; +function reviewCandidates(lessons, opts = {}) { + try { + const max = Number(opts?.max) > 0 ? Math.trunc(Number(opts.max)) : DEFAULT_MAX; + const keep = (Array.isArray(lessons) ? lessons : []).filter((l) => l && typeof l === "object" && str5(l.ref) && str5(l.handle) && !VERDICTS.has(str5(l.explicit).toLowerCase()) && (l.pointer !== true || l.used === true)); + const used = keep.filter((l) => l.used === true); + const rest = keep.filter((l) => l.used !== true); + return [...used, ...rest].slice(0, max); + } catch { + return []; + } +} +function shouldReview(o) { + if (!o || typeof o !== "object") return false; + if (str5(o.outcomeReview) !== "stop") return false; + if (str5(o.outcomeMode) === "off") return false; + if (o.stopHookActive === true) return false; + if (o.alreadyRequested === true) return false; + if (str5(o.apiError)) return false; + if (o.isSubagent === true) return false; + return Array.isArray(o.candidates) && o.candidates.length > 0; +} +function reviewReason(candidates) { + const list2 = (Array.isArray(candidates) ? candidates : []).filter((l) => l && typeof l === "object" && str5(l.handle)); + if (!list2.length) return ""; + const lines = ["Mubit memory review (once per turn). Lessons in your context this turn:"]; + for (const l of list2) { + lines.push(`- [${oneLine(l.handle, 16)}] ${oneLine(l.title, MAX_TITLE_CHARS)}${l.used === true ? " (your reply appears to use it)" : ""}`); + } + lines.push( + 'Call mubit_outcome with reference_id "global", outcome "success" and entry_ids set to the ids that helped; for any that were wrong or misled you, a second call with outcome "failure" and a one-line rationale. Skip the rest.', + "If a lesson was wrong or incomplete, also save a corrected one with mubit_learned.", + 'Then end with one short line starting "Memory review:" naming what you credited; do not repeat your answer.' + ); + return lines.join("\n"); +} +var REVIEW_LINE = /^[ \t]*(?:[-*>][ \t]+)?[*_]{0,2}memory review[*_]{0,2}:.*$/gim; +function stripReviewLine(text) { + return typeof text === "string" ? text.replace(REVIEW_LINE, "").replace(/\n{3,}/g, "\n\n").trim() : ""; +} +function str5(v) { + return typeof v === "string" ? v.trim() : ""; +} +function oneLine(v, max) { + const s = str5(v).replace(/\s+/g, " "); + return s.length > max ? `${s.slice(0, max)}\u2026` : s; +} + +// lib/scorecard.mjs +var DOT = " \xB7 "; +var MAX_TURN_TITLES = 2; +var NEVER_USED_AFTER = 3; +var READ_ONLY_INTENTS = /* @__PURE__ */ new Set(["read", "search"]); +var USED_LABEL_WIDTH = 12; +function foldScorecard(rows, currentPromptId) { + const list2 = Array.isArray(rows) ? rows.filter(isObject7) : []; + const order = []; + const prompts = /* @__PURE__ */ new Map(); + const shown = /* @__PURE__ */ new Map(); + const tools = /* @__PURE__ */ new Map(); + const explicit = /* @__PURE__ */ new Map(); + const turns = /* @__PURE__ */ new Map(); + const clears = []; + let learned = 0; + let tokens = 0; + let pendingStanding = null; + const seePrompt = (id, pos) => { + if (!prompts.has(id)) { + prompts.set(id, { ...markOf(pos), standing: null }); + order.push(id); + } + return ( + /** @type {any} */ + prompts.get(id) + ); + }; + list2.forEach((row, pos) => { + const id = str6(row.prompt_id); + switch (row.kind) { + case "start": + tokens += num2(row.tokens); + pendingStanding = isObject7(row.lessons) ? row.lessons : {}; + if (str6(row.source) === "clear") clears.push(pos); + break; + case "prompt": { + if (!id) break; + const p = seePrompt(id, pos); + flagPrompt(p, row); + if (!p.slash && pendingStanding) { + p.standing = pendingStanding; + pendingStanding = null; + } + break; + } + case "shown": + if (!id) break; + seePrompt(id, pos); + tokens += num2(row.tokens); + if (isObject7(row.lessons)) shown.set(id, { ...shown.get(id) ?? {}, ...row.lessons }); + break; + case "tool": + if (!id) break; + tools.set(id, [...tools.get(id) ?? [], { intent: str6(row.intent), failed: row.failed === true }]); + break; + case "explicit": { + if (!id || !Array.isArray(row.ids)) break; + const m = explicit.get(id) ?? /* @__PURE__ */ new Map(); + for (const ref of row.ids) if (typeof ref === "string" && ref) m.set(ref, str6(row.outcome).toLowerCase()); + explicit.set(id, m); + break; + } + case "learned": + learned++; + break; + case "turn": + if (!id) break; + seePrompt(id, pos); + turns.set(id, row); + break; + default: + break; + } + }); + const turnIds = order.filter((id) => !prompts.get(id)?.slash); + const ordinal = new Map(turnIds.map((id, i) => [id, i + 1])); + const byLesson = /* @__PURE__ */ new Map(); + let lessonPrompts = 0; + let thisTurn = { promptId: str6(currentPromptId), shown: 0, used: [], checkable: false, lessons: [] }; + turnIds.forEach((id, i) => { + const lessons = lessonsOf(prompts.get(id)?.standing ?? null, shown.get(id) ?? null); + const refs = Object.keys(lessons); + if (refs.length) lessonPrompts++; + const turn = turns.get(id) ?? null; + const verdicts = explicit.get(id) ?? /* @__PURE__ */ new Map(); + const next = turnIds[i + 1] ?? ""; + const views = []; + const usedTitles = []; + let checkable = false; + for (const ref of refs) { + const l = lessons[ref]; + const echo = turn && !str6(turn.api_error) && isObject7(turn.lessons) && isObject7(turn.lessons[ref]) ? triState(turn.lessons[ref].used) : null; + const ex = verdicts.get(ref) ?? ""; + let state = echo === true ? "used" : echo === false ? "not" : "unknown"; + let verdict = ""; + if (ex === "success" || ex === "partial") { + state = "used"; + verdict = "worked"; + } else if (ex === "failure") { + state = "used"; + verdict = "failed"; + } else if (ex === "neutral") state = "used"; + if (state === "used" && !verdict) verdict = settle(id, next); + const entry = byLesson.get(ref) ?? { title: "", times: 0, states: [], verdicts: [] }; + entry.title = l.title || entry.title; + entry.times++; + entry.states.push(state); + if (state === "used") entry.verdicts.push({ v: verdict, prompt: num2(ordinal.get(id)) }); + byLesson.set(ref, entry); + if (state !== "unknown") checkable = true; + if (state === "used") usedTitles.push(l.title); + views.push({ + ref, + handle: l.handle, + title: l.title, + pointer: l.pointer, + used: echo, + explicit: ex + }); + } + if (id === str6(currentPromptId)) { + thisTurn = { promptId: id, shown: refs.length, used: usedTitles, checkable, lessons: views }; + } + }); + const counts = { shown: 0, used: 0, notUsed: 0, unknown: 0, worked: 0, failed: 0, waiting: 0 }; + const failed = []; + let neverUsed = 0; + for (const [ref, l] of byLesson) { + counts.shown++; + if (l.states.includes("used")) { + counts.used++; + const bad = l.verdicts.filter((v) => v.v === "failed"); + if (bad.length) { + counts.failed++; + failed.push({ ref, title: l.title, prompt: Math.max(...bad.map((v) => v.prompt)) }); + } else if (l.verdicts.some((v) => v.v === "waiting")) counts.waiting++; + else counts.worked++; + } else if (l.states.includes("not")) { + counts.notUsed++; + if (l.times >= NEVER_USED_AFTER) neverUsed++; + } else { + counts.unknown++; + } + } + failed.sort((a, b) => b.prompt - a.prompt); + return { + prompts: turnIds.length, + lessonPrompts, + learned, + tokens, + lessons: counts, + thisTurn, + review: { failed, neverUsed } + }; + function settle(id, next) { + const nextPrompt = next ? prompts.get(next) : null; + const from = num2(prompts.get(id)?.pos); + const to = num2(nextPrompt?.pos); + if (nextPrompt?.correction && !clears.some((c) => c > from && c < to)) return "failed"; + const acting = (tools.get(id) ?? []).filter((t) => !READ_ONLY_INTENTS.has(t.intent)); + if (acting.length && acting[acting.length - 1].failed) return "failed"; + return nextPrompt ? "worked" : "waiting"; + } +} +function markOf(pos) { + return { slash: false, correction: false, pos, flagged: false }; +} +function flagPrompt(p, row) { + if (p.flagged) return; + p.flagged = true; + p.slash = row.slash === true; + p.correction = row.correction === true; +} +function renderScorecard(summary, mode) { + if (!isObject7(summary) || !summary.thisTurn || num2(summary.thisTurn.shown) <= 0) return ""; + if (mode !== "full" && mode !== "compact") return ""; + const s = ( + /** @type {ScoreSummary} */ + summary + ); + const l = s.lessons; + const verdicts = [ + l.worked ? `${l.worked} worked` : "", + l.failed ? `${l.failed} failed` : "", + l.waiting ? `${l.waiting} waiting` : "" + ].filter(Boolean); + const tail = [ + s.learned ? `+${s.learned} learned` : "", + s.tokens ? `memory added ${formatTokens(s.tokens)} tok` : "" + ].filter(Boolean); + const head = `mubit \xB7 this session \xB7 lessons on ${s.lessonPrompts} of ${s.prompts} ${plural(s.prompts, "prompt")}`; + if (mode === "compact") { + return [head, `${l.used} of ${l.shown} ${plural(l.shown, "lesson")} used`, ...verdicts, ...tail].join(DOT); + } + const lines = [[head, ...tail].join(DOT), ` ${l.shown} ${plural(l.shown, "lesson")} shown`]; + const usedDetail = verdicts.map((v) => v.endsWith(" waiting") ? `${v} on your reply` : v).join(DOT); + const usedLabel = `${l.used} used`; + const tree = [ + l.used ? `${usedLabel.padEnd(Math.max(USED_LABEL_WIDTH, usedLabel.length + 2))}${usedDetail}` : "", + l.notUsed ? `${l.notUsed} not used` : "", + l.unknown ? `${l.unknown} unknown` : "" + ].filter(Boolean); + tree.forEach((row, i) => lines.push(` ${i === tree.length - 1 ? "\u2514" : "\u251C"} ${row}`)); + const t = s.thisTurn; + if (t.used.length) { + const named = t.used.slice(0, MAX_TURN_TITLES).map(quote).join(", "); + const more = t.used.length - MAX_TURN_TITLES; + lines.push(` this turn: used ${named}${more > 0 ? ` +${more} more` : ""}`); + } else { + lines.push(` this turn: shown ${t.shown}, none ${t.checkable ? "used" : "checkable"}`); + } + const review = []; + const failed = s.review?.failed ?? []; + if (failed.length) { + review.push(`${quote(failed[0].title)} failed on prompt ${failed[0].prompt}`); + if (failed.length > 1) review.push(`+${failed.length - 1} more failed`); + } + const never = num2(s.review?.neverUsed); + if (never) review.push(`${never} ${plural(never, "lesson")} shown ${NEVER_USED_AFTER}+ times and never used`); + if (review.length) lines.push(` review: ${review.join(DOT)}`); + return lines.join("\n"); +} +function lessonsOf(standing, recalled) { + const out = {}; + for (const [ref, v] of Object.entries(standing ?? {})) { + if (!ref || !isObject7(v)) continue; + out[ref] = { title: str6(v.title), handle: str6(v.handle), pointer: false }; + } + for (const [ref, v] of Object.entries(recalled ?? {})) { + if (!ref || !isObject7(v)) continue; + const prev = out[ref]; + out[ref] = { + title: str6(v.title) || prev?.title || "", + handle: str6(v.handle) || prev?.handle || "", + pointer: v.pointer === true && (prev ? prev.pointer : true) + }; + } + return out; +} +function formatTokens(n) { + const t = Math.max(0, Math.trunc(num2(n))); + return t >= 1e3 ? `${(t / 1e3).toFixed(1)}k` : String(t); +} +function plural(n, word) { + return n === 1 ? word : `${word}s`; +} +function quote(title) { + return `"${String(title ?? "").replace(/"/g, "'")}"`; +} +function triState(v) { + return v === true ? true : v === false ? false : null; +} +function isObject7(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} +function str6(v) { + return typeof v === "string" ? v : ""; +} +function num2(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +// lib/scorecard-log.mjs +import { + closeSync as closeSync4, + fstatSync as fstatSync3, + openSync as openSync4, + readdirSync as readdirSync2, + readFileSync as readFileSync6, + readSync as readSync3, + statSync as statSync5, + writeSync as writeSync4 +} from "node:fs"; +import { dirname as dirname7, join as join10 } from "node:path"; +var SCORE_LOG_VERSION = 1; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join10(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname7(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync4(p, "a+"); + try { + const st = fstatSync3(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync3(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync4(fd, prefix + line); + } finally { + closeSync4(fd); + } + return true; + } catch { + return false; + } +} +function readScoreRows(cfg, sessionId, opts = {}) { + const p = scorecardPath(cfg, sessionId); + if (!p) return []; + return readRowsAt(p, opts); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync5(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync4(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync3(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync4(fd); + } + partialHead = true; + } else { + text = readFileSync6(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} + +// lib/assemble.mjs +var SECTION_KEYS = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "archive_blocks", + "handoffs", + "feedback", + "facts", + "observations", + "working_memory", + "traces", + "goals", + "checkpoints", + "logs", + "other" +]); +var EMISSION_ORDER = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "facts", + "observations", + "working_memory", + "traces", + "goals" +]); +var RENDER_ORDER = Object.freeze([ + ...EMISSION_ORDER, + ...SECTION_KEYS.filter((k) => !EMISSION_ORDER.includes(k)) +]); +var SECTION_BY_ENTRY_TYPE = Object.freeze({ + mental_model: "mental_models", + rule: "active_rules", + lesson: "lessons", + fact: "facts", + observation: "observations", + working_memory: "working_memory", + goal: "working_memory", + trace: "traces", + tool_output: "traces", + tool_input: "traces", + task_result: "traces", + step_outcome: "traces", + archive_block: "archive_blocks", + checkpoint: "checkpoints", + handoff: "handoffs", + feedback: "feedback" +}); +var HEADINGS = Object.freeze({ + mental_models: "Mental models", + active_rules: "Active rules", + lessons: "Lessons", + archive_blocks: "Archive blocks", + handoffs: "Handoffs", + feedback: "Feedback", + facts: "Facts", + observations: "Observations", + working_memory: "Working memory", + traces: "Traces", + goals: "Goals", + checkpoints: "Checkpoints", + logs: "Logs", + other: "Other" +}); + +// lib/terms.mjs +var TERM_RE = /[A-Za-z][A-Za-z0-9_]{3,23}/g; +var MAX_PROMPT_SCAN = 16 * 1024; +var MAX_ANSWER_SCAN = 64 * 1024; +var TERM_MIN = 4; +var TERM_MAX = 24; +var MAX_MATCHED = 12; +function termSet(s) { + const set = /* @__PURE__ */ new Set(); + for (const m of String(s ?? "").matchAll(TERM_RE)) set.add(m[0].toLowerCase()); + return set; +} +function matchTerms(terms, text) { + return matchIn(haystackOf(text), terms); +} +function evaluateUse(entries, reply, opts = {}) { + const out = {}; + try { + const exclude = new Set([...opts?.exclude ?? []].filter((t) => typeof t === "string").map((t) => t.toLowerCase())); + const list2 = []; + for (const e of Array.isArray(entries) ? entries : []) { + if (!e || typeof e !== "object" || typeof e.ref !== "string" || !e.ref) continue; + const terms = [...new Set((Array.isArray(e.terms) ? e.terms : []).filter((t) => typeof t === "string" && t.length >= TERM_MIN && t.length <= TERM_MAX).map((t) => t.toLowerCase()))]; + list2.push({ ref: e.ref, terms }); + } + const owners = /* @__PURE__ */ new Map(); + for (const e of list2) for (const t of e.terms) owners.set(t, (owners.get(t) ?? 0) + 1); + const text = typeof reply === "string" ? reply : ""; + const hay = text.trim() ? haystackOf(text.slice(0, MAX_ANSWER_SCAN)) : ""; + for (const e of list2) { + const candidates = e.terms.filter((t) => owners.get(t) === 1 && !exclude.has(t)); + const n = candidates.length; + if (n === 0) { + out[e.ref] = { used: null, matched: [], candidates: 0, reason: "no_distinct_terms" }; + continue; + } + if (!hay) { + out[e.ref] = { used: null, matched: [], candidates: n, reason: "no_reply" }; + continue; + } + const matched = matchIn(hay, candidates); + const threshold = n <= 2 ? n : Math.max(2, Math.ceil(n / 4)); + out[e.ref] = { used: matched.length >= threshold, matched: matched.slice(0, MAX_MATCHED), candidates: n }; + } + } catch { + } + return out; +} +function haystackOf(text) { + return ` ${String(text ?? "").toLowerCase().replace(/[^a-z0-9_]+/g, " ")} `; +} +function matchIn(hay, terms) { + return terms.filter((t) => hay.includes(` ${t}`)); +} + // lib/runid.mjs import { spawnSync as spawnSync3 } from "node:child_process"; import { createHash as createHash2 } from "node:crypto"; -import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync6, statSync as statSync5 } from "node:fs"; -import { basename as basename2, dirname as dirname7, join as join10, resolve as resolve5 } from "node:path"; +import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync6 } from "node:fs"; +import { basename as basename2, dirname as dirname8, join as join11, resolve as resolve5 } from "node:path"; var STRATEGIES = /* @__PURE__ */ new Set(["per-directory", "git-branch", "per-conversation", "static"]); var DEFAULT_STRATEGY = "per-directory"; var SOURCES = /* @__PURE__ */ new Set(["startup", "resume", "clear", "compact", "fork"]); @@ -2137,9 +2924,9 @@ var MAX_SESSION_FILE = 128; var GIT_TIMEOUT_MS = 2e3; var TOUCH_INTERVAL_MS = 60 * 1e3; function deriveRunId(cfg, payload = {}, options = {}) { - const c = isObject7(cfg) ? cfg : {}; - const p = isObject7(payload) ? payload : {}; - const persist = !(isObject7(options) && options.persist === false); + const c = isObject8(cfg) ? cfg : {}; + const p = isObject8(payload) ? payload : {}; + const persist = !(isObject8(options) && options.persist === false); return assertUsableRunId(resolveRunId(c, p, persist)); } function resolveRunId(cfg, payload, persist) { @@ -2208,7 +2995,7 @@ function directoryRunId(cfg, payload, withBranch) { return branch ? `cc-${slug}-${branch}-${digest}` : `cc-${slug}-${digest}`; } function reusableRun(cfg, payload, prev, strategy) { - if (!isObject7(prev)) return ""; + if (!isObject8(prev)) return ""; const id = typeof prev.run_id === "string" ? prev.run_id.trim() : ""; if (!id || FORBIDDEN_RUN_IDS.has(id.toLowerCase())) return ""; const recorded = typeof prev.strategy === "string" ? prev.strategy.trim() : ""; @@ -2227,13 +3014,13 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; } function turnKey(payload) { - if (!isObject7(payload)) return ""; + if (!isObject8(payload)) return ""; const prompt = typeof payload.prompt_id === "string" ? payload.prompt_id.trim() : ""; if (prompt) return prompt; const turn = typeof payload.turn_id === "string" ? payload.turn_id.trim() : ""; @@ -2245,7 +3032,7 @@ function turnNumber(cfg, runId, payload) { try { const id = safeSegment(turnKey(payload)); if (!id) return 0; - const staged = readJson(join10(runDir(cfg, runId), "turns", `${id}.json`), null); + const staged = readJson(join11(runDir(cfg, runId), "turns", `${id}.json`), null); const n = Number(staged?.turn_number); return Number.isFinite(n) && n > 0 ? n : 0; } catch { @@ -2253,7 +3040,7 @@ function turnNumber(cfg, runId, payload) { } } function deriveAgentId(payload = {}) { - const p = isObject7(payload) ? payload : {}; + const p = isObject8(payload) ? payload : {}; const role = agentRole(); const sub = subagentShort(p); return sub ? `${role}-sub-${sub}` : role; @@ -2280,7 +3067,7 @@ function loadSessionMap(sessionId) { const file = sessionFileName(sessionId); if (!file) return null; const stored = readJson(sessionPath(file), null); - return isObject7(stored) ? stored : null; + return isObject8(stored) ? stored : null; } catch { return null; } @@ -2288,10 +3075,10 @@ function loadSessionMap(sessionId) { function rememberRun(cfg, payload, sessionId, prev, next) { const now = Date.now(); const isSessionStart = !!next.source || payload.hook_event_name === "SessionStart"; - const moved = !isObject7(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; - const lastSeen = isObject7(prev) ? numberOr2(prev.last_seen_at, 0) : 0; + const moved = !isObject8(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; + const lastSeen = isObject8(prev) ? numberOr2(prev.last_seen_at, 0) : 0; if (!moved && !isSessionStart && now - lastSeen < TOUCH_INTERVAL_MS) return; - const inherited = isObject7(prev) ? prev : {}; + const inherited = isObject8(prev) ? prev : {}; const dir = projectDirOf(cfg, payload); saveSessionMap(sessionId, { ...inherited, @@ -2325,7 +3112,7 @@ function normaliseRecord(record) { clear_count: 0, endpoint_hash: "" }; - if (isObject7(record)) { + if (isObject8(record)) { for (const [k, v] of Object.entries(record)) { if (v !== void 0) out[k] = v; } @@ -2333,7 +3120,7 @@ function normaliseRecord(record) { return out; } function sessionPath(file) { - return join10(dataDir({}), "sessions", `${file}.json`); + return join11(dataDir({}), "sessions", `${file}.json`); } function sessionFileName(sessionId) { const raw = typeof sessionId === "string" ? sessionId.trim() : ""; @@ -2342,10 +3129,10 @@ function sessionFileName(sessionId) { return safe && safe !== "." && safe !== ".." ? safe : ""; } function projectDirOf(cfg, payload = {}) { - return usableDir(isObject7(payload) ? payload.cwd : "") || firstString2(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); + return usableDir(isObject8(payload) ? payload.cwd : "") || firstString2(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); } function resolveProjectDir(cfg, payload = {}) { - return projectDirOf(isObject7(cfg) ? cfg : {}, payload); + return projectDirOf(isObject8(cfg) ? cfg : {}, payload); } function projectRootOf(dir) { return gitToplevel2(dir) || dir; @@ -2354,7 +3141,7 @@ function usableDir(v) { const s = typeof v === "string" ? v.trim() : ""; if (!s) return ""; try { - return statSync5(s).isDirectory() ? s : ""; + return statSync6(s).isDirectory() ? s : ""; } catch { return ""; } @@ -2384,8 +3171,8 @@ function hasGitDir2(start) { try { let cur = resolve5(start); for (let i = 0; i < 24; i++) { - if (existsSync6(join10(cur, ".git"))) return true; - const up = dirname7(cur); + if (existsSync6(join11(cur, ".git"))) return true; + const up = dirname8(cur); if (up === cur) return false; cur = up; } @@ -2393,7 +3180,7 @@ function hasGitDir2(start) { } return false; } -function isObject7(v) { +function isObject8(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function firstString2(...vals) { @@ -2407,7 +3194,7 @@ function numberOr2(v, d) { return Number.isFinite(n) ? n : d; } function clearCount(rec) { - if (!isObject7(rec)) return 0; + if (!isObject8(rec)) return 0; const n = Math.trunc(numberOr2(rec.clear_count, 0)); return n > 0 ? n : 0; } @@ -2420,7 +3207,7 @@ function normaliseSource(v) { return SOURCES.has(s) ? s : ""; } function hostSessionId(payload) { - const v = isObject7(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; + const v = isObject8(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; if (!v || PLACEHOLDER_SESSION_IDS.has(v.toLowerCase())) return ""; return v; } @@ -2448,31 +3235,31 @@ function safeCwd2() { // lib/spool.mjs import { - closeSync as closeSync4, + closeSync as closeSync5, existsSync as existsSync7, linkSync, - openSync as openSync4, - readdirSync as readdirSync3, - readFileSync as readFileSync7, + openSync as openSync5, + readdirSync as readdirSync4, + readFileSync as readFileSync8, renameSync as renameSync4, - statSync as statSync6, + statSync as statSync7, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4, - writeSync as writeSync4 + writeSync as writeSync5 } from "node:fs"; import { createHash as createHash3, randomBytes } from "node:crypto"; -import { join as join11 } from "node:path"; -var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +import { join as join12 } from "node:path"; +var ALPHABET2 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; function spoolDir(cfg, runId) { - return join11(runDir(cfg, runId), "spool"); + return join12(runDir(cfg, runId), "spool"); } function rand6() { let s = ""; try { const b = randomBytes(6); - for (let i = 0; i < 6; i++) s += ALPHABET[b[i] % ALPHABET.length]; + for (let i = 0; i < 6; i++) s += ALPHABET2[b[i] % ALPHABET2.length]; } catch { - for (let i = s.length; i < 6; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + for (let i = s.length; i < 6; i++) s += ALPHABET2[Math.floor(Math.random() * ALPHABET2.length)]; } return s; } @@ -2489,7 +3276,7 @@ function appendItem(cfg, runId, item2) { } if (typeof body !== "string") return ""; for (let attempt2 = 0; attempt2 < 8; attempt2++) { - const target = join11(dir, `${Date.now()}-${rand6()}.json`); + const target = join12(dir, `${Date.now()}-${rand6()}.json`); if (existsSync7(target)) continue; const tmp = `${target}.tmp-${process.pid}`; try { @@ -2516,7 +3303,7 @@ function stampOf(dir, name) { if (Number.isFinite(n)) return n; } try { - return statSync6(join11(dir, name)).mtimeMs; + return statSync7(join12(dir, name)).mtimeMs; } catch { return 0; } @@ -2526,7 +3313,7 @@ function spoolStats(cfg, runId) { const dir = spoolDir(cfg, runId); let entries; try { - entries = readdirSync3(dir, { withFileTypes: true }); + entries = readdirSync4(dir, { withFileTypes: true }); } catch { return { count: 0, oldestMs: 0 }; } @@ -2572,8 +3359,12 @@ var MAX_RENDER_ITEMS = 24; var MAX_VALUE_CHARS = 4096; var MAX_ID_CHARS = 160; var USED_SIGNAL_METHOD = "memory-term-echo/v1"; +var ENTRY_SIGNAL_METHOD = "memory-term-echo/v2-entry"; +var OWN_MCP_PREFIXES3 = ["mcp__plugin_mubit-memory_mubit__", "mcp__mubit__"]; +var VERDICTS2 = /* @__PURE__ */ new Set(["success", "failure", "partial", "neutral"]); +var READ_ONLY_INTENTS2 = /* @__PURE__ */ new Set(["read", "search"]); var MAX_EVIDENCE_TERMS = 12; -var MAX_ANSWER_SCAN = 64 * 1024; +var MAX_ANSWER_SCAN2 = 64 * 1024; var MAX_TERMS_READ = 64; var TERM_MIN_CHARS = 4; var TERM_MAX_CHARS = 24; @@ -2584,8 +3375,7 @@ var MODE2 = pickMode(process.argv.slice(2)); await runHook("capture", { budgetMs: BUDGET_MS, body: (payload, cfg) => { - capture(payload, cfg, MODE2); - return { suppressOutput: true }; + return capture(payload, cfg, MODE2) ?? { suppressOutput: true }; } }); function pickMode(argv) { @@ -2598,46 +3388,78 @@ function pickMode(argv) { return "tool"; } function capture(rawPayload, cfg, mode) { - const payload = isObject8(rawPayload) ? rawPayload : {}; - if (cfg && cfg.capture === false) return; - if ((mode === "tool" || mode === "permission") && SKIP_TOOLS.has(str5(payload.tool_name).trim())) return; + const payload = isObject9(rawPayload) ? rawPayload : {}; + if (cfg && cfg.capture === false) return null; + if ((mode === "tool" || mode === "permission") && SKIP_TOOLS.has(str7(payload.tool_name).trim())) return null; + if (mode === "tool") attempt(() => noteOwnTool(cfg, payload)); if (mode === "tool" || mode === "permission") { - if (attempt(() => isSelfReference(payload.tool_name, payload.tool_input, cfg), false)) return; + if (attempt(() => isSelfReference(payload.tool_name, payload.tool_input, cfg), false)) return null; } if (mode === "tool" || mode === "failure" || mode === "permission") { - if (attempt(() => hasDeniedSubject(payload, cfg), false)) return; + if (attempt(() => hasDeniedSubject(payload, cfg), false)) return null; } const runId = attempt(() => deriveRunId(cfg, payload), ""); - if (!runId) return; + if (!runId) return null; + const pending = mode === "stop" || mode === "stop-failure" ? attempt(() => { + const t = readTurn(cfg, runId, turnKey(payload)); + return reviewPending(t) ? t : null; + }, null) : null; + const followUp = mode === "stop" && payload.stop_hook_active === true && !!pending; + const afterReview = followUp ? stripReviewLine(str7(payload.last_assistant_message) || str7(payload.message)) : ""; + const answered = followUp && !!afterReview && Number(pending?.queued_at) > Number(pending?.review_requested_at); const item2 = attempt( () => { - if (mode === "stop-failure") return null; + if (mode === "stop-failure" || followUp && !answered) return null; if (mode === "permission") return buildPermissionItem(payload, cfg); + if (answered) { + return buildTurnItem({ ...payload, last_assistant_message: afterReview }, cfg, runId, mode, "queued"); + } return mode === "stop" || mode === "subagent" ? buildTurnItem(payload, cfg, runId, mode) : buildToolItem(payload, cfg, mode, runId); }, null ); if (item2) attempt(() => appendItem(cfg, runId, item2)); if (mode === "stop") { - attempt(() => closeTurn(cfg, runId, payload)); + const closed = attempt(() => closeTurn(cfg, runId, payload, { followUp, answered }), null); + const summary = closed ? attempt(() => foldScorecard(closed.rows, closed.promptId), null) : null; + const review = attempt(() => reviewFor(cfg, payload, closed, summary), null); + if (review) { + attempt(() => markReview(cfg, runId, payload, closed, review.ids)); + if (attempt(() => drainTriggerFired(cfg, runId), false)) attempt(() => fireDrain(cfg, runId, payload, [])); + return { decision: "block", reason: review.reason }; + } attempt(() => fireDrain(cfg, runId, payload, outcomeArgs(payload))); - return; + return attempt(() => cardFor(cfg, summary), null); + } + if (mode === "stop-failure" && pending) { + attempt(() => closeTurn(cfg, runId, payload, { followUp: true, reviewError: apiErrorOf(payload) })); + attempt(() => fireDrain(cfg, runId, payload, outcomeArgs(payload))); + return null; } if (mode === "stop-failure") { - attempt(() => closeTurn(cfg, runId, payload, apiErrorOf(payload))); + attempt(() => closeTurn(cfg, runId, payload, { apiError: apiErrorOf(payload) })); } if (attempt(() => drainTriggerFired(cfg, runId), false)) { attempt(() => fireDrain(cfg, runId, payload, [])); } + return null; } function apiErrorOf(payload) { - const raw = str5(payload.error).trim(); + const raw = str7(payload.error).trim(); return raw ? clamp(raw, MAX_API_ERROR_CHARS) : API_ERROR_UNKNOWN; } function buildToolItem(payload, cfg, mode, runId) { const recorded = mode === "tool" && host() === "codex" ? attempt(() => toolCallRecord(payload.transcript_path, payload.tool_use_id), null) : null; const failed = mode === "failure" || !!recorded?.failed; - const toolName = clamp(str5(payload.tool_name) || "Tool", 128); + const toolName = clamp(str7(payload.tool_name) || "Tool", 128); + if (!str7(payload.agent_id) && !ownToolName(payload.tool_name)) { + attempt(() => appendScoreRow(cfg, payload.session_id, { + kind: "tool", + prompt_id: turnKey(payload), + failed, + intent: toolIntent(payload.tool_name, payload.tool_input) + })); + } const changes = failed ? [] : attempt(() => fileChanges(payload.tool_name, payload.tool_input, payload.tool_response), []); if (changes.length) attempt(() => recordFileChanges(cfg, runId, changes)); const cls = attempt( @@ -2659,7 +3481,7 @@ function buildToolItem(payload, cfg, mode, runId) { return item({ cfg, payload, - // §5.4: "item_id is stable per tool call so a retried drain deduplicates." Derived from + // item_id is stable per tool call, so a retried drain deduplicates. Derived from // `tool_use_id` and nothing else — a timestamp in here would make every retry a new // entry, which is the exact failure the dedup exists to prevent. id: `cc-${idPart(payload.tool_use_id) || fallbackId(payload, text)}`, @@ -2668,9 +3490,9 @@ function buildToolItem(payload, cfg, mode, runId) { importance: cls.importance, metadata: { tool: toolName, - tool_use_id: str5(payload.tool_use_id), - hook_event: str5(payload.hook_event_name) || (failed ? "PostToolUseFailure" : "PostToolUse"), - session_id: str5(payload.session_id), + tool_use_id: str7(payload.tool_use_id), + hook_event: str7(payload.hook_event_name) || (failed ? "PostToolUseFailure" : "PostToolUse"), + session_id: str7(payload.session_id), prompt_id: turnKey(payload), // The host names it `duration_ms`; `execution_time_ms` is the older payload name and // stays as a fallback. The metadata key keeps the old spelling because it is already @@ -2694,13 +3516,13 @@ function buildToolItem(payload, cfg, mode, runId) { // files" on every `Read` in the store. ...changes.length ? { files: changes } : {}, truncated: !!(params.truncated || tail.truncated), - redactions: num2(scrubbed.redactions) + num2(params.redactions) + num2(tail.redactions), - ...isObject8(cls.metadata) ? cls.metadata : {} + redactions: num3(scrubbed.redactions) + num3(params.redactions) + num3(tail.redactions), + ...isObject9(cls.metadata) ? cls.metadata : {} } }); } function buildPermissionItem(payload, cfg) { - const toolName = clamp(str5(payload.tool_name) || "Tool", 128); + const toolName = clamp(str7(payload.tool_name) || "Tool", 128); const scrubbed = attempt(() => redactParams(payload.tool_input, cfg), { params: null, redactions: 0 }); const params = attempt( () => redactText(renderParams(scrubbed.params), cfg, "param"), @@ -2716,39 +3538,39 @@ function buildPermissionItem(payload, cfg) { importance: "medium", metadata: { tool: toolName, - hook_event: str5(payload.hook_event_name) || "PermissionRequest", - session_id: str5(payload.session_id), + hook_event: str7(payload.hook_event_name) || "PermissionRequest", + session_id: str7(payload.session_id), prompt_id: turnKey(payload), permission_requested: true, // Deliberately not an `outcome`: this event fires *before* the human answers, and the // plugin never learns what they said. Stamping `ok` here would be a claim about a // decision nobody recorded. truncated: !!params.truncated, - redactions: num2(scrubbed.redactions) + num2(params.redactions) + redactions: num3(scrubbed.redactions) + num3(params.redactions) } }); } -function buildTurnItem(payload, cfg, runId, mode) { +function buildTurnItem(payload, cfg, runId, mode, suffix = "") { const event = mode === "subagent" ? "SubagentStop" : "Stop"; const cls = attempt( () => classifyTurn("", "", { event, - agent_id: str5(payload.agent_id), - agent_type: str5(payload.agent_type) + agent_id: str7(payload.agent_id), + agent_type: str7(payload.agent_type) }), { intent: "task_result", importance: "medium", contentType: "text", agentId: "", agentType: "" } ); const turn = attempt(() => readTurn(cfg, runId, turnKey(payload)), null); const own = mode === "subagent" ? attempt(() => firstUserText(payload.agent_transcript_path), "") : ""; - const staged = own || str5(turn?.prompt) || str5(payload.prompt); - const answer = str5(payload.last_assistant_message) || str5(payload.message); + const staged = own || str7(turn?.prompt) || str7(payload.prompt); + const answer = str7(payload.last_assistant_message) || str7(payload.message); if (!staged && !answer) return null; const q = attempt(() => redactText(staged, cfg, "output"), { text: "", redactions: 0, truncated: false }); const a = attempt(() => redactText(answer, cfg, "output"), { text: "", redactions: 0, truncated: false }); const text = `Q: ${q.text} A: ${a.text}`; - const subAgent = str5(cls.agentId); + const subAgent = str7(cls.agentId); const handoff = mode === "subagent" ? { from_agent_id: attempt(() => deriveAgentId(payload), ""), to_agent_id: attempt(() => deriveAgentId({}), ""), @@ -2759,25 +3581,25 @@ A: ${a.text}`; return item({ cfg, payload, - id: mode === "subagent" ? `cc-sub-${idPart(payload.agent_id) || "anon"}-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}` : `cc-stop-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}`, + id: mode === "subagent" ? `cc-sub-${idPart(payload.agent_id) || "anon"}-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}` : `cc-stop-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}${suffix ? `-${suffix}` : ""}`, text, intent: cls.intent, importance: cls.importance, metadata: { - hook_event: str5(payload.hook_event_name) || event, - session_id: str5(payload.session_id), + hook_event: str7(payload.hook_event_name) || event, + session_id: str7(payload.session_id), prompt_id: turnKey(payload), // Payload first, then the ordinal `stage-prompt` staged — Codex sends no `turn_number` // on any event, so without the second source every item here recorded 0. turn_number: attempt(() => turnNumber(cfg, runId, payload), 0), - ...subAgent ? { agent_id: subAgent, agent_type: str5(cls.agentType) } : {}, + ...subAgent ? { agent_id: subAgent, agent_type: str7(cls.agentType) } : {}, ...subAgent ? { mubit_agent_id: attempt(() => deriveAgentId(payload), "") } : {}, ...handoff, // The path itself, so a later reader can rejoin this subagent to its own rollout — // which is what `persistSubRun` names as the next step on real per-subagent isolation. - ...str5(payload.agent_transcript_path) ? { agent_transcript_path: str5(payload.agent_transcript_path) } : {}, + ...str7(payload.agent_transcript_path) ? { agent_transcript_path: str7(payload.agent_transcript_path) } : {}, truncated: !!(q.truncated || a.truncated), - redactions: num2(q.redactions) + num2(a.redactions) + redactions: num3(q.redactions) + num3(a.redactions) } }); } @@ -2791,8 +3613,7 @@ function item(o) { intent: intentOr(o.intent), importance: importanceOr(o.importance), source: "agent", - // Unix SECONDS, as in the §5.4 example — `occurrence_time` is an int64 of seconds - // (control.proto) and handing it milliseconds dates every memory to the year 57000. + // Unix SECONDS — `occurrence_time` is an int64 of seconds, and handing it milliseconds dates every memory to the year 57000. occurrence_time: Math.floor(Date.now() / 1e3), // The tags are derived from a directory too, and they ride on every ingested item. A // run id that follows a mid-session `cd` while `repo:`/`branch:` stay on the launch repo @@ -2812,18 +3633,18 @@ function item(o) { // empty string would read as "the host said the model is blank". metadata_json: safeJson(withActor(withModel(o.metadata, o.payload), actor)) }; - const userId = str5(cfg.userId); + const userId = str7(cfg.userId); if (userId) out.user_id = userId; return out; } function withModel(metadata, payload) { - const base = isObject8(metadata) ? metadata : {}; + const base = isObject9(metadata) ? metadata : {}; if ("model" in base) return base; - const model = clamp(str5(payload?.model), 128); + const model = clamp(str7(payload?.model), 128); return model ? { ...base, model } : base; } function withActor(metadata, actor) { - const base = isObject8(metadata) ? metadata : {}; + const base = isObject9(metadata) ? metadata : {}; return actor ? { ...base, actor } : base; } function renderParams(params) { @@ -2874,7 +3695,7 @@ function outputText(v, depth = 0) { return v.map((x) => outputText(x, depth + 1)).filter(Boolean).join("\n"); } if (typeof v.text === "string") return v.text; - if (isObject8(v.file) && typeof v.file.content === "string") return v.file.content; + if (isObject9(v.file) && typeof v.file.content === "string") return v.file.content; if (typeof v.content === "string") return v.content; if (Array.isArray(v.content)) return outputText(v.content, depth + 1); if (typeof v.output === "string") return v.output; @@ -2887,31 +3708,57 @@ function outputText(v, depth = 0) { } } function errorText(payload) { - const direct = str5(payload.error) || str5(payload.tool_error) || str5(payload.error_message); + const direct = str7(payload.error) || str7(payload.tool_error) || str7(payload.error_message); if (direct) return direct; return outputText(payload.error ?? payload.tool_response ?? payload.tool_output); } function turnPath(cfg, runId, promptId) { const id = idPart(promptId); if (!id) return ""; - return join12(resolveDataDir(cfg), "runs", idPart(runId), "turns", `${id}.json`); + return join13(resolveDataDir(cfg), "runs", idPart(runId), "turns", `${id}.json`); } function readTurn(cfg, runId, promptId) { const p = turnPath(cfg, runId, promptId); if (!p) return null; const v = readJson(p, null); - return isObject8(v) ? v : null; -} -function closeTurn(cfg, runId, payload, apiError = "") { - const p = turnPath(cfg, runId, turnKey(payload)); - if (!p) return; + return isObject9(v) ? v : null; +} +function closeTurn(cfg, runId, payload, opts = {}) { + const apiError = str7(opts.apiError); + const followUp = opts.followUp === true; + const answered = followUp && opts.answered === true; + const reviewError = str7(opts.reviewError); + const promptId = turnKey(payload); + const p = turnPath(cfg, runId, promptId); + if (!p) return null; const prev = readJson(p, null); - const base = isObject8(prev) ? prev : { - prompt_id: turnKey(payload), - session_id: str5(payload.session_id), + const base = isObject9(prev) ? prev : { + prompt_id: promptId, + session_id: str7(payload.session_id), started_at: Date.now() }; - const evidence = apiError ? null : attempt(() => usedEvidence(base, payload), null); + const sessionId = str7(payload.session_id); + const logged = cfg?.capture !== false && !!sessionId; + const rows = logged ? attempt(() => readScoreRows(cfg, sessionId), []) : []; + const standing = attempt(() => standingFor(rows, promptId), []); + const prevEvidence = isObject9(base.used_evidence) ? base.used_evidence : null; + const raw = str7(payload.last_assistant_message) || str7(payload.message); + const reply = answered ? stripReviewLine(raw) : raw; + const measured = answered ? { ...payload, last_assistant_message: reply } : payload; + let evidence = null; + if (!apiError) { + if (followUp && !answered) { + evidence = prevEvidence; + } else { + const v1 = attempt(() => usedEvidence(base, measured), null); + const entries = attempt(() => entryEvidence(base, standing, measured), null); + const fresh = v1 || entries ? { ...v1 ?? {}, ...entries ? { entry_method: ENTRY_SIGNAL_METHOD, entries } : {} } : null; + evidence = answered ? mergeEvidence(prevEvidence, fresh) : fresh; + } + } + const explicit = attempt(() => explicitFor(rows, promptId, base), { ids: [], byRef: {} }); + const toolFailed = !apiError && attempt(() => lastActingToolFailed(rows, promptId), false); + const endedWithQuestion = followUp && !answered ? base.ended_with_question === true : /\?\s*$/.test(reply.trim()); const closed = { ...base, // Absent when nothing was staged to look for. An absent key means "unmeasured" and the @@ -2920,17 +3767,184 @@ function closeTurn(cfg, runId, payload, apiError = "") { // existed as an injection the model ignored. ...evidence ? { used_evidence: evidence } : {}, ...apiError ? { [API_ERROR_KEY2]: apiError } : {}, + ...explicit.ids.length ? { explicit_ids: explicit.ids, explicit: explicit.byRef } : {}, + ...followUp ? { review_closed_at: Date.now() } : {}, + ...reviewError ? { review_error: reviewError } : {}, + ended_with_question: endedWithQuestion, ended_at: Date.now(), outcome_pending: true }; + if (toolFailed) { + closed.outcome = "failure"; + closed.failure_reason = "tool_failure"; + } else if (str7(base.failure_reason) === "tool_failure") { + delete closed.outcome; + delete closed.failure_reason; + } writeJsonAtomic(p, closed); attempt(() => appendLedger(resolveDataDir(cfg), runId, turnLedgerRow(closed, runId, Date.now()))); + if (logged) { + const entries = isObject9(closed.used_evidence?.entries) ? closed.used_evidence.entries : {}; + const explicitSet = new Set(explicit.ids); + const row = { + kind: "turn", + prompt_id: promptId, + run_id: runId, + lessons: lessonsOfTurn(base, standing, entries), + used_refs: Object.keys(entries).filter((r) => entries[r]?.used === true && !explicitSet.has(r)), + ...apiError ? { api_error: apiError } : {}, + ended_with_question: endedWithQuestion + }; + if (attempt(() => appendScoreRow(cfg, sessionId, row), false)) rows.push({ v: 1, at: Date.now(), ...row }); + } + return { turn: closed, rows, promptId }; +} +function mergeEvidence(prev, next) { + if (!isObject9(prev)) return next; + if (!isObject9(next)) return prev; + const rank = (e) => e?.used === true ? 2 : e?.used === false ? 1 : 0; + const base = rank(next) > rank(prev) ? next : prev; + const a = isObject9(prev.entries) ? prev.entries : {}; + const b = isObject9(next.entries) ? next.entries : {}; + const entries = {}; + for (const ref of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) { + entries[ref] = rank(b[ref]) > rank(a[ref]) ? b[ref] : a[ref]; + } + const { entries: _drop, ...v1 } = base; + return Object.keys(entries).length ? { ...v1, entry_method: ENTRY_SIGNAL_METHOD, entries } : v1; +} +function standingFor(rows, promptId) { + let start = -1; + for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === "start") { + start = i; + break; + } + if (start < 0 || !isObject9(rows[start].lessons)) return []; + const first = rows.slice(start + 1).find((r) => r.kind === "prompt" && r.slash !== true); + if (!first || first.prompt_id !== promptId) return []; + return Object.entries(rows[start].lessons).filter(([ref, v]) => ref && isObject9(v)).map(([ref, v]) => ({ ref, terms: Array.isArray(v.terms) ? v.terms : [] })); +} +function entryEvidence(turn, standing, payload) { + const shown = Array.isArray(turn.shown) ? turn.shown.filter((e) => isObject9(e) && str7(e.ref)) : []; + const refs = new Set(shown.map((e) => e.ref)); + const list2 = [ + ...shown.map((e) => ({ ref: e.ref, terms: Array.isArray(e.terms) ? e.terms : [] })), + ...standing.filter((e) => !refs.has(e.ref)) + ]; + if (!list2.length) return null; + const reply = str7(payload.last_assistant_message) || str7(payload.message); + return evaluateUse(list2, reply, { exclude: termSet(str7(turn.prompt)) }); +} +function lessonsOfTurn(turn, standing, entries) { + const refs = [ + ...(Array.isArray(turn.shown) ? turn.shown : []).filter((e) => isObject9(e) && str7(e.ref) && str7(e.type) === "lesson").map((e) => e.ref), + ...standing.map((e) => e.ref) + ]; + const out = {}; + for (const ref of refs) { + const e = isObject9(entries[ref]) ? entries[ref] : null; + out[ref] = { + used: e && typeof e.used === "boolean" ? e.used : null, + matched: e && Array.isArray(e.matched) ? e.matched.slice(0, MAX_EVIDENCE_TERMS) : [] + }; + } + return out; +} +function explicitFor(rows, promptId, turn) { + const byRef = isObject9(turn.explicit) ? { ...turn.explicit } : {}; + const ids = Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v) : []; + for (const r of rows) { + if (r.kind !== "explicit" || r.prompt_id !== promptId || !Array.isArray(r.ids)) continue; + for (const ref of r.ids) { + if (typeof ref !== "string" || !ref) continue; + if (!ids.includes(ref)) ids.push(ref); + byRef[ref] = str7(r.outcome); + } + } + return { ids, byRef }; +} +function lastActingToolFailed(rows, promptId) { + const acting = rows.filter((r) => r.kind === "tool" && r.prompt_id === promptId && !READ_ONLY_INTENTS2.has(str7(r.intent))); + return acting.length > 0 && acting[acting.length - 1].failed === true; +} +function reviewPending(turn) { + return !!turn && Number(turn.review_requested_at) > 0 && !(Number(turn.review_closed_at) > 0); +} +function reviewFor(cfg, payload, closed, summary) { + if (!closed || !summary) return null; + const candidates = reviewCandidates(summary.thisTurn.lessons); + const ok = shouldReview({ + outcomeReview: str7(cfg?.outcomeReview), + outcomeMode: str7(cfg?.outcomeMode), + stopHookActive: payload.stop_hook_active === true, + alreadyRequested: Number(closed.turn.review_requested_at) > 0, + apiError: str7(closed.turn[API_ERROR_KEY2]), + isSubagent: !!str7(payload.agent_id), + candidates + }); + if (!ok) return null; + const reason = reviewReason(candidates); + return reason ? { ids: candidates.map((c) => c.ref), reason } : null; +} +function markReview(cfg, runId, payload, closed, ids) { + const p = turnPath(cfg, runId, closed.promptId); + if (p) writeJsonAtomic(p, { ...closed.turn, review_requested_at: Date.now(), review_ids: ids }); + appendScoreRow(cfg, payload.session_id, { kind: "review", prompt_id: closed.promptId, ids }); +} +function cardFor(cfg, summary) { + const mode = str7(cfg?.sessionScore) || "full"; + if (!summary || mode === "off") return null; + const text = renderScorecard(summary, mode); + return text ? { systemMessage: text, suppressOutput: true } : null; +} +function ownToolName(name) { + const n = str7(name); + for (const prefix of OWN_MCP_PREFIXES3) if (n.startsWith(prefix)) return n.slice(prefix.length); + return ""; +} +function noteOwnTool(cfg, payload) { + const tool = ownToolName(payload.tool_name); + if (tool !== "mubit_outcome" && tool !== "mubit_learned") return; + if (resultIsError(payload.tool_response)) return; + const sessionId = str7(payload.session_id); + if (!sessionId) return; + const promptId = turnKey(payload); + if (tool === "mubit_learned") { + appendScoreRow(cfg, sessionId, { kind: "learned", prompt_id: promptId }); + return; + } + if (str7(payload.agent_id)) return; + const input = isObject9(payload.tool_input) ? payload.tool_input : {}; + const outcome = str7(input.outcome).trim().toLowerCase(); + if (!VERDICTS2.has(outcome)) return; + const primary = str7(input.reference_id).trim(); + const raw = [ + ...primary && primary !== "global" ? [primary] : [], + ...Array.isArray(input.entry_ids) ? input.entry_ids : [] + ]; + const resolved = resolveHandles(raw, knownRefsFromRows(readScoreRows(cfg, sessionId))); + const unresolved = new Set(resolved.unresolved); + const ids = [...new Set(resolved.ids.filter((id) => id && id !== "global" && !unresolved.has(id)))]; + if (!ids.length) return; + appendScoreRow(cfg, sessionId, { kind: "explicit", prompt_id: promptId, ids, outcome }); + const p = turnPath(cfg, deriveRunId(cfg, payload), promptId); + const prev = p ? readJson(p, null) : null; + if (!isObject9(prev)) return; + const explicitIds = Array.isArray(prev.explicit_ids) ? prev.explicit_ids.filter((v) => typeof v === "string") : []; + writeJsonAtomic(p, { + ...prev, + explicit_ids: [.../* @__PURE__ */ new Set([...explicitIds, ...ids])], + explicit: { ...isObject9(prev.explicit) ? prev.explicit : {}, ...Object.fromEntries(ids.map((id) => [id, outcome])) } + }); +} +function resultIsError(response) { + return isObject9(response) && response.isError === true; } function usedEvidence(turn, payload) { - const recall = isObject8(turn.recall) ? turn.recall : null; + const recall = isObject9(turn.recall) ? turn.recall : null; if (!recall || !Array.isArray(recall.terms)) return null; const terms = recall.terms.filter((t) => typeof t === "string" && t.length >= TERM_MIN_CHARS && t.length <= TERM_MAX_CHARS).map((t) => t.toLowerCase()).slice(0, MAX_TERMS_READ); - const answer = str5(payload.last_assistant_message) || str5(payload.message); + const answer = str7(payload.last_assistant_message) || str7(payload.message); const out = { method: USED_SIGNAL_METHOD, at: Date.now(), @@ -2947,16 +3961,12 @@ function usedEvidence(turn, payload) { out.reason = "no_reply"; return out; } - const hits = matchTerms(terms, answer.slice(0, MAX_ANSWER_SCAN)); + const hits = matchTerms(terms, answer.slice(0, MAX_ANSWER_SCAN2)); out.matched = hits.length; out.terms = hits.slice(0, MAX_EVIDENCE_TERMS); out.used = hits.length > 0; return out; } -function matchTerms(terms, text) { - const hay = ` ${text.toLowerCase().replace(/[^a-z0-9_]+/g, " ")} `; - return terms.filter((t) => hay.includes(` ${t}`)); -} function drainTriggerFired(cfg, runId) { const stats = spoolStats(cfg, runId); if (!stats || stats.count <= 0) return false; @@ -2970,14 +3980,14 @@ function outcomeArgs(payload) { } function fireDrain(cfg, runId, payload, args) { const handoff = stashPayload(cfg, { - hook_event_name: str5(payload.hook_event_name), - session_id: str5(payload.session_id), + hook_event_name: str7(payload.hook_event_name), + session_id: str7(payload.session_id), prompt_id: turnKey(payload), - transcript_path: str5(payload.transcript_path), - cwd: str5(payload.cwd), - permission_mode: str5(payload.permission_mode), - agent_id: str5(payload.agent_id), - agent_type: str5(payload.agent_type), + transcript_path: str7(payload.transcript_path), + cwd: str7(payload.cwd), + permission_mode: str7(payload.permission_mode), + agent_id: str7(payload.agent_id), + agent_type: str7(payload.agent_type), // Resolved here rather than in the child: the child reads this stash, not the payload. turn_number: attempt(() => turnNumber(cfg, runId, payload), 0), run_id: runId @@ -2985,15 +3995,15 @@ function fireDrain(cfg, runId, payload, args) { spawnDetached(cfg, "drain", args, handoff); } function hasDeniedSubject(payload, cfg) { - const input = isObject8(payload.tool_input) ? payload.tool_input : {}; - const projectDir = str5(cfg?.projectDir); + const input = isObject9(payload.tool_input) ? payload.tool_input : {}; + const projectDir = str7(cfg?.projectDir); for (const key of PATH_KEYS3) { const v = input[key]; if (typeof v === "string" && v && isDeniedPath(v, cfg, projectDir)) return true; } const edits = Array.isArray(input.edits) ? input.edits.slice(0, MAX_RENDER_ITEMS) : []; for (const e of edits) { - if (!isObject8(e)) continue; + if (!isObject9(e)) continue; for (const key of PATH_KEYS3) { const v = e[key]; if (typeof v === "string" && v && isDeniedPath(v, cfg, projectDir)) return true; @@ -3014,13 +4024,13 @@ function attempt(fn, fallback = ( return fallback; } } -function isObject8(v) { +function isObject9(v) { return !!v && typeof v === "object" && !Array.isArray(v); } -function str5(v) { +function str7(v) { return typeof v === "string" ? v : ""; } -function num2(v) { +function num3(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } @@ -3041,7 +4051,7 @@ function idPart(v) { } function fallbackId(payload, text) { let h = 2166136261; - const seed = `${str5(payload.session_id)}|${turnKey(payload)}|${str5(payload.tool_name)}|${text}`; + const seed = `${str7(payload.session_id)}|${turnKey(payload)}|${str7(payload.tool_name)}|${text}`; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; @@ -3049,11 +4059,11 @@ function fallbackId(payload, text) { return `anon-${h.toString(16).padStart(8, "0")}`; } function intentOr(v) { - const s = str5(v).trim(); + const s = str7(v).trim(); return s && s !== "unclassified" ? s : "tool_output"; } function importanceOr(v) { - const s = str5(v).trim().toLowerCase(); + const s = str7(v).trim().toLowerCase(); return ["low", "medium", "high", "critical"].includes(s) ? s : "medium"; } function safeJson(v) { diff --git a/integrations/claude-code/hooks/dist/impl/checkpoint.mjs b/integrations/claude-code/hooks/dist/impl/checkpoint.mjs index 66adb8c..c5f1c92 100755 --- a/integrations/claude-code/hooks/dist/impl/checkpoint.mjs +++ b/integrations/claude-code/hooks/dist/impl/checkpoint.mjs @@ -279,7 +279,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE2 = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION2 = 3; +var CACHE_VERSION2 = 4; var MAX_ENV_TAGS = 8; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); @@ -463,6 +463,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -536,6 +546,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1485,7 +1497,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1558,7 +1570,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1649,7 +1661,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1862,7 +1874,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2081,6 +2093,43 @@ function safeCwd2() { } } +// lib/codex-rollout.mjs +var TAIL_BYTES = 512 * 1024; +var HEAD_BYTES = 256 * 1024; +var INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; +var USER_ACTION_RE = /^\s*<(?:turn_aborted|user_shell_command)\b/; +var HOOK_PROMPT_OPEN_RE = /\s*]*)?>/y; +var HOOK_PROMPT_CLOSE = ""; +function isHookFeedback(text) { + const end = text.trimEnd().length; + let at = 0; + do { + HOOK_PROMPT_OPEN_RE.lastIndex = at; + if (!HOOK_PROMPT_OPEN_RE.test(text)) return false; + const close = text.indexOf(HOOK_PROMPT_CLOSE, HOOK_PROMPT_OPEN_RE.lastIndex); + if (close === -1) return false; + at = close + HOOK_PROMPT_CLOSE.length; + } while (at < end); + return true; +} +function isInjectedUserText(text, opts) { + if (typeof text !== "string") return false; + if (INJECTED_USER_RE.test(text) || isHookFeedback(text)) return true; + return !opts?.keepUserActions && USER_ACTION_RE.test(text); +} +function stripInjectedBlocks(content, opts) { + if (typeof content === "string") return isInjectedUserText(content, opts) ? "" : content; + if (!Array.isArray(content)) return content; + return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text, opts))); +} +var NOT_FOUND = Object.freeze({ + found: false, + failed: false, + exitCode: null, + durationMs: null, + status: "" +}); + // lib/transcript.mjs var CHUNK_BYTES = 256 * 1024; var MAX_LINE_BYTES = 2 * 1024 * 1024; @@ -2130,7 +2179,9 @@ function renderEntry(line, opts = {}) { const entry = parseLine(s); if (!entry) return s; const message = messageRecord(entry); - const body = messageText(message.content ?? entry.content ?? entry.text, opts); + let content = message.content ?? entry.content ?? entry.text; + if (message === entry.payload && str2(message.role) === "user") content = stripInjectedBlocks(content, { keepUserActions: true }); + const body = messageText(content, opts); if (!body.trim()) return ""; const role = str2(message.role) || str2(entry.role) || str2(entry.type) || "message"; return `${role}: ${body}`; @@ -2447,7 +2498,7 @@ ${tail}`, cfg, "output"), ); const actor = attempt(() => readActor(cfg), ""); appendItem(cfg, runId, { - // §1.3: `item_id` and `content_type` are REQUIRED — a missing one is a 422 for the whole + // `item_id` and `content_type` are REQUIRED — a missing one is a 422 for the whole // batch. Derived from (session, counter) and never from a clock, so a retried drain // deduplicates instead of writing a second anchor for one compaction. item_id: clamp(`cc-precompact-${idPart(payload.session_id) || idPart(runId) || "anon"}-${label.slice(LABEL_PREFIX.length)}`, MAX_ID_CHARS), @@ -2456,7 +2507,7 @@ ${tail}`, cfg, "output"), intent: str3(cls.intent) || "checkpoint", importance: importanceOr(cls.importance), source: "agent", - // Unix SECONDS (`control.proto`); milliseconds here dates every memory to the year 57000. + // Unix SECONDS; milliseconds here dates every memory to the year 57000. occurrence_time: Math.floor(Date.now() / 1e3), // From the payload's directory, not the launch one: after a mid-session `cd` the run id // follows the new repo, and `repo:`/`branch:` have to follow it or the item lands in the diff --git a/integrations/claude-code/hooks/dist/impl/cwd-changed.mjs b/integrations/claude-code/hooks/dist/impl/cwd-changed.mjs index 7bb31fc..3a0b6d3 100755 --- a/integrations/claude-code/hooks/dist/impl/cwd-changed.mjs +++ b/integrations/claude-code/hooks/dist/impl/cwd-changed.mjs @@ -183,7 +183,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -289,6 +289,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -362,6 +372,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1119,7 +1131,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -1306,7 +1318,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; diff --git a/integrations/claude-code/hooks/dist/impl/drain.mjs b/integrations/claude-code/hooks/dist/impl/drain.mjs index f9ece6c..09b52df 100755 --- a/integrations/claude-code/hooks/dist/impl/drain.mjs +++ b/integrations/claude-code/hooks/dist/impl/drain.mjs @@ -159,6 +159,9 @@ function pruneStale(cfg = {}) { for (const name of jsonFiles(join(root, "import"))) { expire(join(root, "import", name), 30 * DAY); } + for (const e of dirEntries(join(root, "scorecard"))) { + if (e.isFile() && e.name.endsWith(".jsonl")) expire(join(root, "scorecard", e.name), 7 * DAY); + } for (const e of dirEntries(join(root, "tmp"))) { if (e.isFile()) expire(join(root, "tmp", e.name), 1 * HOUR); } @@ -639,7 +642,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE2 = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION2 = 3; +var CACHE_VERSION2 = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -759,6 +762,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -832,6 +845,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -997,15 +1012,50 @@ function implicitOutcomesEnabled(cfg) { function decideOutcome(turn) { if (!isObject2(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str2(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject2(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str2(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str2(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -1015,8 +1065,36 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) + }; +} +function decideCorrection(turn) { + if (!isObject2(turn)) return { post: false, reason: "not_a_turn" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; + if (str2(turn.outcome).toLowerCase() === "failure") return { post: false, reason: "already_failed" }; + const ev = isObject2(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (!entries) return { post: false, reason: "nothing_used" }; + const explicit = new Set(explicitIdsOf(turn)); + const ids = Object.keys(entries).filter((r) => entries[r].used === true && !explicit.has(r)); + if (ids.length === 0) return { post: false, reason: "nothing_used" }; + return { + post: true, + outcome: OUTCOME_FAILURE, + signal: SIGNAL_FAILURE, + entryIds: ids, + rationale: `The user's next prompt corrected this Claude Code turn; the reply had used ${ids.length} ${ids.length === 1 ? "memory" : "memories"} (memory-term-echo/v2-entry).` + }; +} +function correctionIdempotencyKey(runId, promptId) { + return `cc-correction-${str2(runId)}-${str2(promptId)}`; +} +function correctionRequest(o) { + return { + ...outcomeRequest(o), + idempotency_key: correctionIdempotencyKey(o.runId, o.promptId) }; } function outcomeIdempotencyKey(runId, promptId) { @@ -1035,17 +1113,40 @@ function outcomeRequest(o) { idempotency_key: outcomeIdempotencyKey(o.runId, o.promptId) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str2(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + } + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str2(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; + } + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject2(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject2(v)) out[ref] = /** @type {any} */ + v; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject2(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -1429,7 +1530,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1499,7 +1600,7 @@ async function postOutcome(cfg, req, opts = {}) { req, "reference_id", "postOutcome", - 'pass "global" for run-level attribution and put the real ids in entry_ids[] (\xA71.3)' + 'pass "global" for run-level attribution and put the real ids in entry_ids[]' ) ); if (bad) return refuse(cfg, started, bad, { route: ROUTES.outcome }); @@ -1519,7 +1620,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1610,7 +1711,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireItems(req) { const items = req && typeof req === "object" ? req.items : void 0; @@ -1738,7 +1839,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -1814,6 +1915,13 @@ function updateMarker(cfg, runId, patch = {}) { // lib/pins.mjs import { join as join9 } from "node:path"; +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ "mental_models", @@ -2172,7 +2280,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2605,6 +2713,7 @@ var HARD_STOP_MS = 12e3; var JOBS_KEEP = 20; var LOCK_CONFIRM_MS = 20; var OUTCOME_LOCK_WAIT_MS = 2e3; +var CORRECTION_LOCK_WAIT_MS = HARD_STOP_MS + 3e3; var LOCK_POLL_MS = 25; var DEFAULT_BATCH = 32; var RETRYABLE_4XX = /* @__PURE__ */ new Set([401, 403, 404, 408, 429]); @@ -2648,6 +2757,7 @@ async function main() { const outcomeArg = flagValue(argv, "--with-outcome"); const wantsOutcome = argv.includes("--with-outcome"); const pinnedRun = flagValue(argv, "--run"); + const correctArg = str3(flagValue(argv, "--correct")); const payload = await readPayload(payloadPath); const cfg = loadConfig(process.env); cfgRef = cfg; @@ -2668,7 +2778,8 @@ async function main() { } const agentId = deriveAgentId(payload); const promptId = str3(outcomeArg) || turnKey(payload); - const lock = await acquireConfirmed(cfg, runId, wantsOutcome, started); + const waitMs = correctArg ? CORRECTION_LOCK_WAIT_MS : wantsOutcome ? OUTCOME_LOCK_WAIT_MS : 0; + const lock = await acquireConfirmed(cfg, runId, waitMs, started); if (!lock) { log(cfg, "debug", "drain: another drainer holds the lock; standing down", { run_id: runId }); return; @@ -2685,6 +2796,7 @@ async function main() { try { const drained = await drainSpool(cfg, runId, agentId, promptId, started); await flushOutcome(cfg, runId, agentId, promptId, wantsOutcome); + if (correctArg && !breakerOpen(cfg)) await sendCorrection(cfg, runId, agentId, correctArg); log(cfg, "info", `drain: ${drained.sent} item(s) in ${drained.batches} batch(es)`, { run_id: runId, rejected: drained.rejected, @@ -2757,7 +2869,7 @@ async function drainSpool(cfg, runId, agentId, promptId, started) { } return { sent, batches, rejected }; } -async function acquireConfirmed(cfg, runId, wantsOutcome, started) { +async function acquireConfirmed(cfg, runId, waitMs, started) { for (; ; ) { const lock = acquireDrainLock(cfg, runId); if (lock) { @@ -2765,7 +2877,7 @@ async function acquireConfirmed(cfg, runId, wantsOutcome, started) { if (await stillOurs(lock)) return lock; heldLock = null; } - if (!wantsOutcome || Date.now() - started >= OUTCOME_LOCK_WAIT_MS) return null; + if (Date.now() - started >= waitMs) return null; await sleep(LOCK_POLL_MS); } } @@ -2911,6 +3023,36 @@ async function sendOutcome(cfg, runId, agentId, promptId) { log(cfg, "warn", `drain: outcome skipped \u2014 ${messageOf3(err)}`, { run_id: runId }); } } +async function sendCorrection(cfg, runId, agentId, promptId) { + try { + if (!implicitOutcomesEnabled(cfg)) return; + const p = join12(runDir(cfg, runId), "turns", `${safeSegment(promptId)}.json`); + const turn = readJson(p, null); + const decision = decideCorrection(turn); + if (!decision.post) { + log(cfg, "debug", `drain: no correction to post (${decision.reason})`, { run_id: runId, prompt_id: promptId }); + return; + } + const res = await postOutcome( + cfg, + correctionRequest({ runId, agentId, promptId, decision }), + { timeoutMs: numOr2(cfg.timeoutMs, 4e3) } + ); + if (!res.ok) { + log(cfg, "warn", `drain: correction post failed (${res.state})`, { run_id: runId, prompt_id: promptId }); + return; + } + const fresh2 = readJson(p, turn); + writeJsonAtomic(p, { ...fresh2 && typeof fresh2 === "object" ? fresh2 : turn, correction_sent_at: Date.now() }); + appendLedger( + resolveDataDir(cfg), + runId, + { ...outcomeLedgerRow(runId, promptId, decision, 1), correction: true } + ); + } catch (err) { + log(cfg, "warn", `drain: correction skipped \u2014 ${messageOf3(err)}`, { run_id: runId }); + } +} function outcomeLedgerRow(runId, promptId, decision, attempts) { return { v: 1, diff --git a/integrations/claude-code/hooks/dist/impl/pre-tool.mjs b/integrations/claude-code/hooks/dist/impl/pre-tool.mjs index 6f70886..fdf8ec4 100755 --- a/integrations/claude-code/hooks/dist/impl/pre-tool.mjs +++ b/integrations/claude-code/hooks/dist/impl/pre-tool.mjs @@ -183,7 +183,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -289,6 +289,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -362,6 +372,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1197,7 +1209,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; diff --git a/integrations/claude-code/hooks/dist/impl/prompt-recall.mjs b/integrations/claude-code/hooks/dist/impl/prompt-recall.mjs index 83a0200..3626d91 100755 --- a/integrations/claude-code/hooks/dist/impl/prompt-recall.mjs +++ b/integrations/claude-code/hooks/dist/impl/prompt-recall.mjs @@ -2,7 +2,36 @@ // mubit-memory — generated by esbuild. Do not edit; edit hooks/src or lib. // hooks/src/prompt-recall.mjs -import { join as join15 } from "node:path"; +import { join as join16 } from "node:path"; + +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} +function stripHandles(text) { + return String(text ?? "").replace(TAG_RE, " "); +} // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ @@ -98,12 +127,14 @@ function assembleContext(evidence, opts = {}) { if (!isObject(e)) continue; const text = oneLine(e.content); if (!text) continue; - const section = sectionFor(str(e.origin_entry_type) || str(e.entry_type)); + const type = (str(e.origin_entry_type) || str(e.entry_type)).toLowerCase(); + const section = sectionFor(type); if (allowed && !allowed.has(section)) continue; candidates++; const bucket = bySection.get(section) ?? []; bucket.push({ ref: str(e.reference_id), + type, text, score: finite(e.score, 0), stale: e.is_stale === true, @@ -119,12 +150,14 @@ function assembleContext(evidence, opts = {}) { sourceRefIds: [], dropped: 0, pointers: 0, - emptyReason: "no_evidence" + emptyReason: "no_evidence", + entries: [] }; } const parts = []; const sourceRefIds = []; const seenRefs = /* @__PURE__ */ new Set(); + const entries = []; const sections = []; let used = 0; let rendered = 0; @@ -142,9 +175,10 @@ function assembleContext(evidence, opts = {}) { if (perSection > 0 && count >= perSection) { continue; } - const full = `- ${item.stale ? "(stale) " : ""}${item.text} + const tag = handleTag(item.ref); + const full = `- ${tag ? `${tag} ` : ""}${item.stale ? "(stale) " : ""}${item.text} `; - const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${item.ref} \u2014 ${firstClause(item.text)} + const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${tag} \u2014 ${firstClause(item.text)} ` : ""; const degraded = !!pointer && pointer.length < full.length; const line = degraded ? pointer : full; @@ -163,6 +197,18 @@ function assembleContext(evidence, opts = {}) { seenRefs.add(item.ref); sourceRefIds.push(item.ref); } + if (!item.ref || entries.every((x) => x.ref !== item.ref)) { + entries.push({ + ref: item.ref, + handle: handleFor(item.ref), + section: key, + type: item.type, + text: item.text, + title: firstClause(item.text), + pointer: degraded, + stale: item.stale + }); + } } if (count > 0) sections.push({ section: key, count }); } @@ -178,7 +224,8 @@ function assembleContext(evidence, opts = {}) { // A degraded entry is rendered, not dropped: it counts here and nowhere else, so a // reader can tell a block that shrank from a block that lost half its evidence. pointers, - emptyReason: rendered > 0 ? "" : "budget_exhausted" + emptyReason: rendered > 0 ? "" : "budget_exhausted", + entries: rendered > 0 ? entries : [] }; } function isPointerLine(line) { @@ -650,6 +697,7 @@ function takeCarry(cfg, runId) { pointers: int(raw.pointers, 0), emptyReason: typeof raw.empty_reason === "string" ? raw.empty_reason : "", refIds, + entries: entriesOf(raw.entries), writtenAt, forPromptId: typeof raw.for_prompt_id === "string" ? raw.for_prompt_id : "", fetchMs: int(raw.fetch_ms, 0) @@ -658,6 +706,11 @@ function takeCarry(cfg, runId) { return null; } } +var MAX_ENTRIES = 64; +function entriesOf(v) { + if (!Array.isArray(v)) return []; + return v.filter((e) => isObject2(e) && typeof e.ref === "string").slice(0, MAX_ENTRIES); +} function isObject2(v) { return !!v && typeof v === "object" && !Array.isArray(v); } @@ -714,7 +767,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; var MAX_ENV_TAGS = 8; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); @@ -898,6 +951,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -971,6 +1034,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1728,7 +1793,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -1839,7 +1904,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1921,7 +1986,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -2012,13 +2077,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -2495,30 +2560,30 @@ async function ladder(cfg, o) { limit: QUERY_LIMIT, entry_types: [...ENTRY_TYPES], include_working_memory: true, - // `env_tags` is accepted on the query route but not on the context route — version-aware - // tag scoring is capability rungs 1-2 gain over rung 3, not something they give up. + // `env_tags` is accepted on the query route but not on the context route — a capability + // rungs 1-2 gain over rung 3, not something they give up. // Tagged from the directory this prompt was sent in, not the one the session launched - // in — the same reason the run id reads the payload. A recall scored against `repo:` - // tags from the wrong repo is worse than one scored against none. + // in — the same reason the run id reads the payload. A recall tagged with the wrong repo + // is worse than an untagged one. env_tags: envTags(cfg, o.projectDir), // `rank_by` is the same trap as `env_tags` above, one field further on. `/context` // accepts no ranking field of ANY kind, // which makes freshness the second capability rungs 1-2 gain over rung 3 rather than // something they give up. What makes it a trap rather than a limitation: turning rung 3 // on (`recallAssemble: "server"`) does not fail, warn, or fall back: it silently reverts - // every recall to the default fusion weights, and "where were we?" quietly goes back to + // every recall to the default ordering, and "where were we?" quietly goes back to // answering with whatever is most similar. Documented in the README's `recallAssemble` // row for the same reason. // // Omitted rather than sent when it resolves to nothing: absent IS `relevance` // server-side, so there is no shape of request this spread cannot express. ...rankBy ? { rank_by: rankBy } : {}, - // §5.2: opting out of the cross-run lesson overlay, and the ONLY field here that is sent - // to make the request cheaper rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. + // Opting out of cross-run lessons, and the ONLY field here that is sent to make the + // request faster rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. // - // Omitted rather than sent as `false` when the overlay is wanted: absent IS `false` - // server-side, and a request log that only ever shows the field when somebody declined - // the lane is easier to read than one where every request carries it. + // Omitted rather than sent as `false` when they are wanted: absent IS `false`, and a + // request log that only ever shows the field when somebody declined them is easier to + // read than one where every request carries it. ...crossRun ? {} : { prefer_current_run: true } }; let denied = readPolicyDenial(cfg); @@ -2533,7 +2598,7 @@ async function ladder(cfg, o) { if (res2.status === 403) { cachePolicyDenial(cfg); if (cfg.recallFallback !== "agent_routed") { - log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to pay an LLM call per prompt instead.', { run_id: o.runId }); + log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to accept a slower recall instead.', { run_id: o.runId }); return empty(1, "policy_denied"); } log( @@ -2598,7 +2663,8 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function fromEvidence(cfg, responseBody, rung, o) { @@ -2620,9 +2686,10 @@ function fromEvidence(cfg, responseBody, rung, o) { dropped: a.dropped, pointers: a.pointers, emptyReason: a.emptyReason, - // §4.10/§5.5: a degraded entry is still in here. Dropping a repeat would break + // A degraded entry is still in here. Dropping a repeat would break // attribution for exactly the memories that are helping most. - refIds: a.sourceRefIds + refIds: a.sourceRefIds, + entries: a.entries }; } function tokenBudgetOf(cfg, o) { @@ -2650,7 +2717,8 @@ function empty(rung, reason) { dropped: 0, pointers: 0, emptyReason: reason, - refIds: [] + refIds: [], + entries: [] }; } function failure(state, error, rung) { @@ -2664,6 +2732,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; @@ -2841,7 +2910,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -3144,13 +3213,57 @@ function num5(v, d) { return Number.isFinite(n) ? n : d; } -// hooks/src/prompt-recall.mjs -var MIN_PROMPT_CHARS = 8; -var MAX_QUERY_CHARS = 2e3; -var DOT = " \xB7 "; -var MAX_RECALL_TERMS = 48; +// lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync3, + readFileSync as readFileSync6, + readSync, + statSync as statSync5, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname5, join as join15 } from "node:path"; +var SCORE_LOG_VERSION = 1; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join15(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname5(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} + +// lib/terms.mjs var TERM_RE = /[A-Za-z][A-Za-z0-9_]{3,23}/g; -var MAX_PROMPT_SCAN = 16 * 1024; var TERM_STOPWORDS = /* @__PURE__ */ new Set([ "about", "after", @@ -3237,7 +3350,90 @@ var TERM_STOPWORDS = /* @__PURE__ */ new Set([ "would", "your" ]); -var MAX_ID = 128; +var MAX_RECALL_TERMS = 48; +var MAX_PROMPT_SCAN = 16 * 1024; +var MAX_ANSWER_SCAN = 64 * 1024; +var MAX_ENTRY_TERMS = 32; +var MAX_TITLE_CHARS = 48; +var PLACEHOLDER_RE = /\[REDACTED:[^\]]*\]/gi; +function termSet(s) { + const set = /* @__PURE__ */ new Set(); + for (const m of String(s ?? "").matchAll(TERM_RE)) set.add(m[0].toLowerCase()); + return set; +} +function vocabularyOf(block) { + const lines = String(block ?? "").split("\n"); + const bullets = lines.filter((l) => l.startsWith("- ")); + if (bullets.length === 0) return stripHandles(lines.filter((l) => !l.startsWith("#")).join("\n")); + return bullets.filter((l) => !isPointerLine(l)).map((l) => stripHandles(l.slice(2)).trimStart().replace(/^\(stale\)\s+/, "")).join("\n"); +} +function memoryTerms(cfg, blocks, prompt) { + try { + let text = (Array.isArray(blocks) ? blocks : []).filter(Boolean).map(vocabularyOf).filter(Boolean).join("\n"); + if (!text) return []; + try { + text = String(redactText(text, cfg, "output")?.text ?? ""); + } catch { + return []; + } + text = text.replace(PLACEHOLDER_RE, " "); + const fromPrompt = termSet(String(prompt ?? "").slice(0, MAX_PROMPT_SCAN)); + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of text.matchAll(TERM_RE)) { + const t = m[0].toLowerCase(); + if (seen.has(t) || fromPrompt.has(t) || TERM_STOPWORDS.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= MAX_RECALL_TERMS) break; + } + return out; + } catch { + return []; + } +} +function entryTerms(cfg, text, promptTerms = /* @__PURE__ */ new Set(), max = MAX_ENTRY_TERMS) { + try { + const raw = stripHandles(String(text ?? "")); + if (!raw.trim()) return []; + const scrubbed = String(redactText(raw, cfg, "output")?.text ?? "").replace(PLACEHOLDER_RE, " "); + const skip = promptTerms instanceof Set ? promptTerms : /* @__PURE__ */ new Set(); + const cap = Number.isFinite(max) && max > 0 ? Math.trunc(max) : MAX_ENTRY_TERMS; + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of scrubbed.matchAll(TERM_RE)) { + const t = m[0].toLowerCase(); + if (seen.has(t) || skip.has(t) || TERM_STOPWORDS.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= cap) break; + } + return out; + } catch { + return []; + } +} +function entryTitle(cfg, text, max = MAX_TITLE_CHARS) { + try { + const whole = String(text ?? "").replace(/\s+/g, " ").trim(); + let clause = firstClause(whole); + if (clause.endsWith("\u2026") && whole.replace(/[.;!?]+$/, "") === clause.slice(0, -1)) { + clause = clause.slice(0, -1); + } + const t = String(redactText(clause, cfg, "output")?.text ?? "").trim(); + const cap = Number.isFinite(max) && max > 1 ? Math.trunc(max) : MAX_TITLE_CHARS; + return t.length > cap ? `${t.slice(0, cap - 1).trimEnd()}\u2026` : t; + } catch { + return ""; + } +} + +// hooks/src/prompt-recall.mjs +var MIN_PROMPT_CHARS = 8; +var MAX_QUERY_CHARS = 2e3; +var DOT = " \xB7 "; +var HANDLE_LINE_RE = /^- (?:\(seen earlier\) )?(?:\(stale\) )?\[m[a-z2-9]{4}\]/m; +var MAX_ID2 = 128; var CFG = safeConfig2(); var RECALL_BUDGET_MS = clampInt(CFG.recallBudgetMs, 1500, 50, 1e4); var HARNESS_BUDGET_MS = Math.min(RECALL_BUDGET_MS + 400, 2800); @@ -3251,7 +3447,8 @@ var NO_RECALL = Object.freeze({ dropped: 0, pointers: 0, emptyReason: "", - refIds: Object.freeze([]) + refIds: Object.freeze([]), + entries: Object.freeze([]) }); await runHook("prompt-recall", { budgetMs: HARNESS_BUDGET_MS, @@ -3296,7 +3493,7 @@ await runHook("prompt-recall", { if (!resume) return pinsOnly(cfg, pins, runId); persistRecalled(cfg, runId, safeId(turnKey(payload)), payload, NO_RECALL, resume); if (pins.text) updateMarker(cfg, runId, { recall: { pin_tokens: pins.tokens } }); - return injection(runId, NO_RECALL, resume, pins, Date.now() - started); + return injection(cfg, runId, NO_RECALL, resume, pins, Date.now() - started); } const query = prompt.slice(0, MAX_QUERY_CHARS); const rankBy = rankForRecall(cfg, query); @@ -3318,7 +3515,7 @@ await runHook("prompt-recall", { if (!resume) return pinsOnly(cfg, pins, runId); persistRecalled(cfg, runId, promptId, payload, NO_RECALL, resume); if (pins.text) updateMarker(cfg, runId, { recall: { pin_tokens: pins.tokens } }); - return injection(runId, NO_RECALL, resume, pins, ms); + return injection(cfg, runId, NO_RECALL, resume, pins, ms); } persistRecalled(cfg, runId, promptId, payload, outcome, resume); markSeen(cfg, runId, outcome.refIds, sessionId); @@ -3340,7 +3537,7 @@ await runHook("prompt-recall", { } }); if (!outcome.block && !resume) return pinsOnly(cfg, pins, runId, true); - return injection(runId, outcome, resume, pins, ms); + return injection(cfg, runId, outcome, resume, pins, ms); } }); function carryForward(cfg, payload, runId, sessionId, started, pins, resume = null) { @@ -3386,7 +3583,7 @@ function carryForward(cfg, payload, runId, sessionId, started, pins, resume = nu spawnRefresh(cfg, payload, runId); } if (!rendered && !resume) return pinsOnly(cfg, pins, runId, true); - return injection(runId, rendered ? carry : NO_RECALL, resume, pins, ms, true); + return injection(cfg, runId, rendered ? carry : NO_RECALL, resume, pins, ms, true); } function claimResume(cfg, runId, sessionId) { try { @@ -3426,9 +3623,10 @@ function spawnRefresh(cfg, payload, runId) { function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) { try { if (!promptId) return; - const file = join15(resolveDataDir(cfg), "runs", safeId(runId), "turns", `${promptId}.json`); + const file = join16(resolveDataDir(cfg), "runs", safeId(runId), "turns", `${promptId}.json`); const prev = readJson(file, null); const base = isObject10(prev) ? prev : {}; + const prevRecall = isObject10(base.recall) ? base.recall : null; const next = { ...base, prompt_id: promptId, @@ -3436,6 +3634,7 @@ function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) // stages ids, so that they too can be reinforced or corrected. Deduped: the same // entry reached through two lanes must not be reinforced twice for one turn. recalled: [.../* @__PURE__ */ new Set([ + ...Array.isArray(base.recalled) ? base.recalled.filter((v) => typeof v === "string" && v) : [], ...claimStandingLessons(cfg, runId), ...resume ? resume.refIds : [], ...outcome.refIds @@ -3445,7 +3644,7 @@ function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) rung: outcome.rung, sources: (outcome.refIds.length || outcome.sources) + (resume ? resume.refIds.length || resume.sources : 0), tokens: outcome.tokens + (resume ? resume.tokens : 0), - // The token figure is a four-chars-per-token estimate (§4.10). Characters are what + // The token figure is a four-chars-per-token estimate. Characters are what // was actually injected, so a later reader can re-derive the estimate rather than // inherit it. chars: outcome.block.length + (resume ? resume.block.length : 0), @@ -3458,36 +3657,67 @@ function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) terms: memoryTerms(cfg, [resume?.block ?? "", outcome.block], str5(payload?.prompt)) } }; + if (prevRecall) { + for (const k of ["sources", "tokens", "chars", "pointers"]) { + next.recall[k] = (Number(prevRecall[k]) || 0) + (Number(next.recall[k]) || 0); + } + const terms = Array.isArray(prevRecall.terms) ? prevRecall.terms : []; + next.recall.terms = [.../* @__PURE__ */ new Set([...terms, ...next.recall.terms])]; + } if (typeof next.session_id !== "string") next.session_id = str5(payload?.session_id); if (!Number.isFinite(next.started_at)) next.started_at = Date.now(); + const promptTerms = termSet(str5(payload?.prompt).slice(0, MAX_PROMPT_SCAN)); + const entries = (Array.isArray(outcome.entries) ? outcome.entries : []).filter((e) => e && typeof e.ref === "string" && e.ref); + const shown = entries.map((e) => ({ + ref: e.ref, + handle: str5(e.handle), + type: str5(e.type), + pointer: e.pointer === true, + terms: entryTerms(cfg, str5(e.text), promptTerms) + })); + next.shown = mergeShown(Array.isArray(base.shown) ? base.shown : [], shown); writeJsonAtomic(file, next); + if (cfg.capture !== false) { + appendShownRow(cfg, payload, promptId, entries, shown, outcome, resume); + } } catch (err) { log(cfg, "warn", `prompt-recall: could not stage recalled ids (${messageOf3(err)})`, { run_id: runId }); } } -function memoryTerms(cfg, blocks, prompt) { +function mergeShown(prev, fresh2) { + const out = prev.filter((e) => isObject10(e) && typeof e.ref === "string" && e.ref); + for (const e of fresh2) { + const at = out.findIndex((o) => o.ref === e.ref); + if (at < 0) out.push(e); + else if (out[at].pointer === true && !e.pointer) out[at] = e; + } + return out; +} +function appendShownRow(cfg, payload, promptId, entries, shown, outcome, resume) { try { - let text = blocks.filter(Boolean).map(vocabularyOf).filter(Boolean).join("\n"); - if (!text) return []; - try { - text = str5(redactText(text, cfg, "output")?.text) || ""; - } catch { - return []; - } - text = text.replace(/\[REDACTED:[^\]]*\]/gi, " "); - const fromPrompt = termSet(prompt.slice(0, MAX_PROMPT_SCAN)); - const out = []; - const seen = /* @__PURE__ */ new Set(); - for (const m of text.matchAll(TERM_RE)) { - const t = m[0].toLowerCase(); - if (seen.has(t) || fromPrompt.has(t) || TERM_STOPWORDS.has(t)) continue; - seen.add(t); - out.push(t); - if (out.length >= MAX_RECALL_TERMS) break; - } - return out; + const lessons = {}; + entries.forEach((e, i) => { + if (str5(e.type) !== "lesson") return; + lessons[e.ref] = { + title: entryTitle(cfg, str5(e.text)), + terms: shown[i].terms, + handle: shown[i].handle, + pointer: shown[i].pointer + }; + }); + const refs = [...new Set([ + ...outcome.refIds, + ...entries.map((e) => e.ref), + ...resume ? resume.refIds : [] + ].filter((r) => typeof r === "string" && r))]; + appendScoreRow(cfg, str5(payload?.session_id), { + kind: "shown", + prompt_id: promptId, + lessons, + refs, + tokens: outcome.tokens + (resume ? resume.tokens : 0) + }); } catch { - return []; } } function claimStandingLessons(cfg, runId) { @@ -3502,17 +3732,6 @@ function claimStandingLessons(cfg, runId) { return []; } } -function vocabularyOf(block) { - const lines = String(block ?? "").split("\n"); - const bullets = lines.filter((l) => l.startsWith("- ")); - if (bullets.length === 0) return lines.filter((l) => !l.startsWith("#")).join("\n"); - return bullets.filter((l) => !isPointerLine(l)).map((l) => l.slice(2).replace(/^\(stale\)\s+/, "")).join("\n"); -} -function termSet(s) { - const set = /* @__PURE__ */ new Set(); - for (const m of String(s ?? "").matchAll(TERM_RE)) set.add(m[0].toLowerCase()); - return set; -} function noteFailure(cfg, runId, outcome, ms) { const state = str5(outcome.state); const known = isConnState2(state); @@ -3557,7 +3776,7 @@ function breakerOpen(cfg) { return false; } } -function injection(runId, outcome, resume, pins, ms, carried = false) { +function injection(cfg, runId, outcome, resume, pins, ms, carried = false) { const recallSources = outcome.refIds.length || outcome.sources; const resumeSources = resume ? resume.refIds.length || resume.sources : 0; const sources = recallSources + resumeSources; @@ -3567,7 +3786,7 @@ function injection(runId, outcome, resume, pins, ms, carried = false) { if (resume) parts.push(resumeWrap(runId, resumeSources, resume.tokens, resume.block)); if (outcome.block || pinned) { parts.push( - wrap(runId, recallSources, outcome.tokens, outcome.block, outcome.pointers, carried, pinned) + wrap(cfg, runId, recallSources, outcome.tokens, outcome.block, outcome.pointers, carried, pinned) ); } return { @@ -3594,7 +3813,7 @@ function pinsOnly(cfg, pins, runId, stamped = false) { return { hookSpecificOutput: { hookEventName: "UserPromptSubmit", - additionalContext: wrap(runId, 0, 0, "", 0, false, pins) + additionalContext: wrap(cfg, runId, 0, 0, "", 0, false, pins) }, systemMessage: `mubit: ${n} pinned${DOT}${formatTokens(pins.tokens)} tok`, suppressOutput: true @@ -3608,11 +3827,12 @@ function pinsGate(cfg, payload) { return SUPPRESS; } } -function wrap(runId, sources, tokens, block, pointers = 0, carried = false, pins = null) { +function wrap(cfg, runId, sources, tokens, block, pointers = 0, carried = false, pins = null) { const pinned = pins && pins.text ? pins : null; const recalled = typeof block === "string" && block !== ""; + const nudge = recalled && HANDLE_LINE_RE.test(block) && cfg?.outcomeReview !== "off"; return ` -` + (pinned ? `${pinned.text}${recalled ? "Those were pinned for this run and hold until they are cleared. Everything below them was retrieved for this prompt.\n" : ""}` : "") + (recalled ? "Recalled from memory of earlier work \u2014 it may be incomplete or out of date, so verify against the code before relying on it.\n" : "") + (recalled && carried ? "It was retrieved against the previous message in this conversation, not this one, so treat it as background rather than as an answer to what was just asked.\n" : "") + (recalled && pointers > 0 ? `A line marked "${POINTER_MARK}" was injected in full earlier in this conversation and is repeated here only as a reference; ask mubit_dereference for its text. +` + (pinned ? `${pinned.text}${recalled ? "Those were pinned for this run and hold until they are cleared. Everything below them was retrieved for this prompt.\n" : ""}` : "") + (recalled ? "Recalled from memory of earlier work \u2014 it may be incomplete or out of date, so verify against the code before relying on it.\n" : "") + (nudge ? "Each entry starts with its id in brackets. Before you finish, report the entries that helped or misled you with mubit_outcome, passing those ids.\n" : "") + (recalled && carried ? "It was retrieved against the previous message in this conversation, not this one, so treat it as background rather than as an answer to what was just asked.\n" : "") + (recalled && pointers > 0 ? `A line marked "${POINTER_MARK}" was injected in full earlier in this conversation and is repeated here only as a reference; ask mubit_dereference with its id for the text. ` : "") + (recalled ? ` ${block.replace(/\s+$/, "")} ` : "") + ""; @@ -3622,7 +3842,7 @@ function formatTokens(n) { return t >= 1e3 ? `${(t / 1e3).toFixed(1)}k` : String(t); } function safeId(v) { - return safeSegment(v, MAX_ID); + return safeSegment(v, MAX_ID2); } function isObject10(v) { return !!v && typeof v === "object" && !Array.isArray(v); diff --git a/integrations/claude-code/hooks/dist/impl/recall-refresh.mjs b/integrations/claude-code/hooks/dist/impl/recall-refresh.mjs index 62a7ca6..0d3b5b5 100755 --- a/integrations/claude-code/hooks/dist/impl/recall-refresh.mjs +++ b/integrations/claude-code/hooks/dist/impl/recall-refresh.mjs @@ -192,7 +192,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; var MAX_ENV_TAGS = 8; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); @@ -376,6 +376,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -449,6 +459,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1439,12 +1451,21 @@ function writeCarry(cfg, runId, outcome, meta = {}) { dropped: int2(outcome?.dropped, 0), pointers: int2(outcome?.pointers, 0), empty_reason: typeof outcome?.emptyReason === "string" ? outcome.emptyReason : "", - ref_ids: Array.isArray(outcome?.refIds) ? outcome.refIds.filter((v) => typeof v === "string" && v.trim()) : [] + ref_ids: Array.isArray(outcome?.refIds) ? outcome.refIds.filter((v) => typeof v === "string" && v.trim()) : [], + entries: entriesOf(outcome?.entries) }); } catch { return false; } } +var MAX_ENTRIES = 64; +function entriesOf(v) { + if (!Array.isArray(v)) return []; + return v.filter((e) => isObject2(e) && typeof e.ref === "string").slice(0, MAX_ENTRIES); +} +function isObject2(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} function num2(v, d) { const n = typeof v === "number" ? v : Number(v); return Number.isFinite(n) ? n : d; @@ -1463,7 +1484,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -1531,6 +1552,32 @@ import { createHash as createHash3 } from "node:crypto"; import { unlinkSync as unlinkSync4 } from "node:fs"; import { join as join9 } from "node:path"; +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} + // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ "mental_models", @@ -1612,7 +1659,7 @@ function estimateTokens(text) { return Math.ceil(text.length / CHARS_PER_TOKEN); } function assembleContext(evidence, opts = {}) { - const o = isObject2(opts) ? opts : {}; + const o = isObject3(opts) ? opts : {}; const budget = positiveInt2(o.tokenBudget, DEFAULT_TOKEN_BUDGET); const perSection = positiveInt2(o.perSection, 0); const allowed = Array.isArray(o.sections) && o.sections.length ? new Set(o.sections.filter((s) => typeof s === "string").map((s) => s.trim())) : null; @@ -1622,15 +1669,17 @@ function assembleContext(evidence, opts = {}) { let candidates = 0; for (let i = 0; i < list2.length; i++) { const e = list2[i]; - if (!isObject2(e)) continue; + if (!isObject3(e)) continue; const text = oneLine(e.content); if (!text) continue; - const section = sectionFor(str2(e.origin_entry_type) || str2(e.entry_type)); + const type = (str2(e.origin_entry_type) || str2(e.entry_type)).toLowerCase(); + const section = sectionFor(type); if (allowed && !allowed.has(section)) continue; candidates++; const bucket = bySection.get(section) ?? []; bucket.push({ ref: str2(e.reference_id), + type, text, score: finite(e.score, 0), stale: e.is_stale === true, @@ -1646,12 +1695,14 @@ function assembleContext(evidence, opts = {}) { sourceRefIds: [], dropped: 0, pointers: 0, - emptyReason: "no_evidence" + emptyReason: "no_evidence", + entries: [] }; } const parts = []; const sourceRefIds = []; const seenRefs = /* @__PURE__ */ new Set(); + const entries = []; const sections = []; let used = 0; let rendered = 0; @@ -1669,9 +1720,10 @@ function assembleContext(evidence, opts = {}) { if (perSection > 0 && count >= perSection) { continue; } - const full = `- ${item.stale ? "(stale) " : ""}${item.text} + const tag = handleTag(item.ref); + const full = `- ${tag ? `${tag} ` : ""}${item.stale ? "(stale) " : ""}${item.text} `; - const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${item.ref} \u2014 ${firstClause(item.text)} + const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${tag} \u2014 ${firstClause(item.text)} ` : ""; const degraded = !!pointer && pointer.length < full.length; const line = degraded ? pointer : full; @@ -1690,6 +1742,18 @@ function assembleContext(evidence, opts = {}) { seenRefs.add(item.ref); sourceRefIds.push(item.ref); } + if (!item.ref || entries.every((x) => x.ref !== item.ref)) { + entries.push({ + ref: item.ref, + handle: handleFor(item.ref), + section: key, + type: item.type, + text: item.text, + title: firstClause(item.text), + pointer: degraded, + stale: item.stale + }); + } } if (count > 0) sections.push({ section: key, count }); } @@ -1705,7 +1769,8 @@ function assembleContext(evidence, opts = {}) { // A degraded entry is rendered, not dropped: it counts here and nowhere else, so a // reader can tell a block that shrank from a block that lost half its evidence. pointers, - emptyReason: rendered > 0 ? "" : "budget_exhausted" + emptyReason: rendered > 0 ? "" : "budget_exhausted", + entries: rendered > 0 ? entries : [] }; } function firstClause(text) { @@ -1734,7 +1799,7 @@ function oneLine(v) { function str2(v) { return typeof v === "string" ? v.trim() : ""; } -function isObject2(v) { +function isObject3(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function finite(v, d) { @@ -1783,7 +1848,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1865,7 +1930,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1956,13 +2021,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -2101,11 +2166,11 @@ function readRules(cfg, runId) { try { if (!runId) return []; const stored = readJson(join8(runDir(cfg, runId), RULES_FILE), null); - if (!isObject3(stored)) return []; + if (!isObject4(stored)) return []; if (!Array.isArray(stored.rules)) return []; const out = []; for (const r of stored.rules) { - if (!isObject3(r)) continue; + if (!isObject4(r)) continue; const text = clamp(r.text); if (!text) continue; out.push({ ref: segment(r.ref), text }); @@ -2117,7 +2182,7 @@ function readRules(cfg, runId) { } } function normalise(e) { - if (!isObject3(e)) return null; + if (!isObject4(e)) return null; if (e.is_stale === true) return null; const type = str3(e.origin_entry_type) || str3(e.entry_type) || str3(e.lesson_type); if (type.toLowerCase() !== "rule") return null; @@ -2152,7 +2217,7 @@ function segment(v) { function str3(v) { return typeof v === "string" ? v.trim() : ""; } -function isObject3(v) { +function isObject4(v) { return !!v && typeof v === "object" && !Array.isArray(v); } @@ -2203,30 +2268,30 @@ async function ladder(cfg, o) { limit: QUERY_LIMIT, entry_types: [...ENTRY_TYPES], include_working_memory: true, - // `env_tags` is accepted on the query route but not on the context route — version-aware - // tag scoring is capability rungs 1-2 gain over rung 3, not something they give up. + // `env_tags` is accepted on the query route but not on the context route — a capability + // rungs 1-2 gain over rung 3, not something they give up. // Tagged from the directory this prompt was sent in, not the one the session launched - // in — the same reason the run id reads the payload. A recall scored against `repo:` - // tags from the wrong repo is worse than one scored against none. + // in — the same reason the run id reads the payload. A recall tagged with the wrong repo + // is worse than an untagged one. env_tags: envTags(cfg, o.projectDir), // `rank_by` is the same trap as `env_tags` above, one field further on. `/context` // accepts no ranking field of ANY kind, // which makes freshness the second capability rungs 1-2 gain over rung 3 rather than // something they give up. What makes it a trap rather than a limitation: turning rung 3 // on (`recallAssemble: "server"`) does not fail, warn, or fall back: it silently reverts - // every recall to the default fusion weights, and "where were we?" quietly goes back to + // every recall to the default ordering, and "where were we?" quietly goes back to // answering with whatever is most similar. Documented in the README's `recallAssemble` // row for the same reason. // // Omitted rather than sent when it resolves to nothing: absent IS `relevance` // server-side, so there is no shape of request this spread cannot express. ...rankBy ? { rank_by: rankBy } : {}, - // §5.2: opting out of the cross-run lesson overlay, and the ONLY field here that is sent - // to make the request cheaper rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. + // Opting out of cross-run lessons, and the ONLY field here that is sent to make the + // request faster rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. // - // Omitted rather than sent as `false` when the overlay is wanted: absent IS `false` - // server-side, and a request log that only ever shows the field when somebody declined - // the lane is easier to read than one where every request carries it. + // Omitted rather than sent as `false` when they are wanted: absent IS `false`, and a + // request log that only ever shows the field when somebody declined them is easier to + // read than one where every request carries it. ...crossRun ? {} : { prefer_current_run: true } }; let denied = readPolicyDenial(cfg); @@ -2241,7 +2306,7 @@ async function ladder(cfg, o) { if (res2.status === 403) { cachePolicyDenial(cfg); if (cfg.recallFallback !== "agent_routed") { - log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to pay an LLM call per prompt instead.', { run_id: o.runId }); + log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to accept a slower recall instead.', { run_id: o.runId }); return empty(1, "policy_denied"); } log( @@ -2292,11 +2357,11 @@ async function rungThree(cfg, o) { return fromContext(res.body, 3); } function fromContext(responseBody, rung) { - const b = isObject4(responseBody) ? responseBody : {}; + const b = isObject5(responseBody) ? responseBody : {}; const block = typeof b.context_block === "string" ? b.context_block.trim() : ""; const refIds = Array.isArray(b.sources) ? [...new Set(b.sources.filter((s) => typeof s === "string" && s.trim()).map((s) => s.trim()))] : []; const summaries = Array.isArray(b.section_summaries) ? b.section_summaries : []; - const counted = summaries.reduce((n, s) => n + (isObject4(s) ? numOr(s.count, 0) : 0), 0); + const counted = summaries.reduce((n, s) => n + (isObject5(s) ? numOr(s.count, 0) : 0), 0); return { failed: false, rung, @@ -2306,11 +2371,12 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function fromEvidence(cfg, responseBody, rung, o) { - const b = isObject4(responseBody) ? responseBody : {}; + const b = isObject5(responseBody) ? responseBody : {}; const evidence = Array.isArray(b.evidence) ? b.evidence : []; if (o?.runId) recordRules(cfg, o.runId, evidence); const a = assembleContext(evidence, { @@ -2328,9 +2394,10 @@ function fromEvidence(cfg, responseBody, rung, o) { dropped: a.dropped, pointers: a.pointers, emptyReason: a.emptyReason, - // §4.10/§5.5: a degraded entry is still in here. Dropping a repeat would break + // A degraded entry is still in here. Dropping a repeat would break // attribution for exactly the memories that are helping most. - refIds: a.sourceRefIds + refIds: a.sourceRefIds, + entries: a.entries }; } function tokenBudgetOf(cfg, o) { @@ -2358,7 +2425,8 @@ function empty(rung, reason) { dropped: 0, pointers: 0, emptyReason: reason, - refIds: [] + refIds: [], + entries: [] }; } function failure(state, error, rung) { @@ -2372,6 +2440,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; @@ -2384,7 +2453,7 @@ function policyPath(cfg) { function readPolicyDenial(cfg) { try { const v = readJson(policyPath(cfg), null); - if (!isObject4(v) || v.direct_bypass !== "denied") return false; + if (!isObject5(v) || v.direct_bypass !== "denied") return false; const ttl = intOr(cfg.policyTtlMs, 0) || intOr(v.ttl_ms, 0) || POLICY_TTL_MS; const observed = numOr(v.observed_at, 0); return observed > 0 && Date.now() - observed < ttl; @@ -2413,7 +2482,7 @@ function remaining(cfg, deadline2) { if (left <= 0) return 0; return Math.max(1, Math.min(left, intOr(cfg.timeoutMs, 4e3))); } -function isObject4(v) { +function isObject5(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function numOr(v, d) { @@ -2459,9 +2528,9 @@ var MAX_SESSION_FILE = 128; var GIT_TIMEOUT_MS = 2e3; var TOUCH_INTERVAL_MS = 60 * 1e3; function deriveRunId(cfg, payload = {}, options = {}) { - const c = isObject5(cfg) ? cfg : {}; - const p = isObject5(payload) ? payload : {}; - const persist = !(isObject5(options) && options.persist === false); + const c = isObject6(cfg) ? cfg : {}; + const p = isObject6(payload) ? payload : {}; + const persist = !(isObject6(options) && options.persist === false); return assertUsableRunId(resolveRunId(c, p, persist)); } function resolveRunId(cfg, payload, persist) { @@ -2530,7 +2599,7 @@ function directoryRunId(cfg, payload, withBranch) { return branch ? `cc-${slug}-${branch}-${digest}` : `cc-${slug}-${digest}`; } function reusableRun(cfg, payload, prev, strategy) { - if (!isObject5(prev)) return ""; + if (!isObject6(prev)) return ""; const id = typeof prev.run_id === "string" ? prev.run_id.trim() : ""; if (!id || FORBIDDEN_RUN_IDS.has(id.toLowerCase())) return ""; const recorded = typeof prev.strategy === "string" ? prev.strategy.trim() : ""; @@ -2549,20 +2618,20 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; } function turnKey(payload) { - if (!isObject5(payload)) return ""; + if (!isObject6(payload)) return ""; const prompt = typeof payload.prompt_id === "string" ? payload.prompt_id.trim() : ""; if (prompt) return prompt; const turn = typeof payload.turn_id === "string" ? payload.turn_id.trim() : ""; return turn; } function deriveAgentId(payload = {}) { - const p = isObject5(payload) ? payload : {}; + const p = isObject6(payload) ? payload : {}; const role = agentRole(); const sub = subagentShort(p); return sub ? `${role}-sub-${sub}` : role; @@ -2589,7 +2658,7 @@ function loadSessionMap(sessionId) { const file = sessionFileName(sessionId); if (!file) return null; const stored = readJson(sessionPath(file), null); - return isObject5(stored) ? stored : null; + return isObject6(stored) ? stored : null; } catch { return null; } @@ -2597,10 +2666,10 @@ function loadSessionMap(sessionId) { function rememberRun(cfg, payload, sessionId, prev, next) { const now = Date.now(); const isSessionStart = !!next.source || payload.hook_event_name === "SessionStart"; - const moved = !isObject5(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; - const lastSeen = isObject5(prev) ? numberOr2(prev.last_seen_at, 0) : 0; + const moved = !isObject6(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; + const lastSeen = isObject6(prev) ? numberOr2(prev.last_seen_at, 0) : 0; if (!moved && !isSessionStart && now - lastSeen < TOUCH_INTERVAL_MS) return; - const inherited = isObject5(prev) ? prev : {}; + const inherited = isObject6(prev) ? prev : {}; const dir = projectDirOf(cfg, payload); saveSessionMap(sessionId, { ...inherited, @@ -2634,7 +2703,7 @@ function normaliseRecord(record) { clear_count: 0, endpoint_hash: "" }; - if (isObject5(record)) { + if (isObject6(record)) { for (const [k, v] of Object.entries(record)) { if (v !== void 0) out[k] = v; } @@ -2651,10 +2720,10 @@ function sessionFileName(sessionId) { return safe && safe !== "." && safe !== ".." ? safe : ""; } function projectDirOf(cfg, payload = {}) { - return usableDir(isObject5(payload) ? payload.cwd : "") || firstString(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); + return usableDir(isObject6(payload) ? payload.cwd : "") || firstString(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); } function resolveProjectDir(cfg, payload = {}) { - return projectDirOf(isObject5(cfg) ? cfg : {}, payload); + return projectDirOf(isObject6(cfg) ? cfg : {}, payload); } function projectRootOf(dir) { return gitToplevel2(dir) || dir; @@ -2702,7 +2771,7 @@ function hasGitDir2(start) { } return false; } -function isObject5(v) { +function isObject6(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function firstString(...vals) { @@ -2716,7 +2785,7 @@ function numberOr2(v, d) { return Number.isFinite(n) ? n : d; } function clearCount(rec) { - if (!isObject5(rec)) return 0; + if (!isObject6(rec)) return 0; const n = Math.trunc(numberOr2(rec.clear_count, 0)); return n > 0 ? n : 0; } @@ -2729,7 +2798,7 @@ function normaliseSource(v) { return SOURCES.has(s) ? s : ""; } function hostSessionId(payload) { - const v = isObject5(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; + const v = isObject6(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; if (!v || PLACEHOLDER_SESSION_IDS.has(v.toLowerCase())) return ""; return v; } @@ -2774,12 +2843,12 @@ function readSeen(cfg, runId, sessionId = "") { const p = seenPath(cfg, runId, sessionId); if (!p) return emptySeen(); const raw = readJson(p, null); - if (!isObject6(raw) || !isObject6(raw.refs)) return emptySeen(); + if (!isObject7(raw) || !isObject7(raw.refs)) return emptySeen(); const cutoff = Date.now() - SEEN_TTL_MS; const out = emptySeen(); out.updatedAt = num3(raw.updated_at, 0); for (const [id, v] of Object.entries(raw.refs)) { - if (!id || !id.trim() || !isObject6(v)) continue; + if (!id || !id.trim() || !isObject7(v)) continue; const last = num3(v.last, 0); if (!(last > 0) || last < cutoff) continue; const first = num3(v.first, 0); @@ -2795,7 +2864,7 @@ function readSeen(cfg, runId, sessionId = "") { return emptySeen(); } } -function isObject6(v) { +function isObject7(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function num3(v, d) { @@ -2837,7 +2906,7 @@ await runHook("recall-refresh", { query, deadline: started + REFRESH_BUDGET_MS, seen, - // §5.2 — the same rule over the same query text `prompt-recall` would have used. + // The same rule over the same query text `prompt-recall` would have used. // Carry-forward moves WHEN the call happens, never what it asks for: a handoff prompt // ranked by similarity in the background is the same bug, one turn later. rankBy: rankForRecall(cfg, query), diff --git a/integrations/claude-code/hooks/dist/impl/session-end.mjs b/integrations/claude-code/hooks/dist/impl/session-end.mjs index 3da064c..790ec1e 100755 --- a/integrations/claude-code/hooks/dist/impl/session-end.mjs +++ b/integrations/claude-code/hooks/dist/impl/session-end.mjs @@ -2,8 +2,8 @@ // mubit-memory — generated by esbuild. Do not edit; edit hooks/src or lib. // hooks/src/session-end.mjs -import { readdirSync as readdirSync4, unlinkSync as unlinkSync5 } from "node:fs"; -import { join as join11 } from "node:path"; +import { readdirSync as readdirSync5, unlinkSync as unlinkSync5 } from "node:fs"; +import { join as join12 } from "node:path"; // lib/breaker.mjs import { createHash } from "node:crypto"; @@ -158,6 +158,9 @@ function pruneStale(cfg = {}) { for (const name of jsonFiles(join(root, "import"))) { expire(join(root, "import", name), 30 * DAY); } + for (const e of dirEntries(join(root, "scorecard"))) { + if (e.isFile() && e.name.endsWith(".jsonl")) expire(join(root, "scorecard", e.name), 7 * DAY); + } for (const e of dirEntries(join(root, "tmp"))) { if (e.isFile()) expire(join(root, "tmp", e.name), 1 * HOUR); } @@ -537,7 +540,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -657,6 +660,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -730,6 +743,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -895,15 +910,50 @@ function implicitOutcomesEnabled(cfg) { function decideOutcome(turn) { if (!isObject(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str2(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str2(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str2(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -913,8 +963,36 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) + }; +} +function decideCorrection(turn) { + if (!isObject(turn)) return { post: false, reason: "not_a_turn" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; + if (str2(turn.outcome).toLowerCase() === "failure") return { post: false, reason: "already_failed" }; + const ev = isObject(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (!entries) return { post: false, reason: "nothing_used" }; + const explicit = new Set(explicitIdsOf(turn)); + const ids = Object.keys(entries).filter((r) => entries[r].used === true && !explicit.has(r)); + if (ids.length === 0) return { post: false, reason: "nothing_used" }; + return { + post: true, + outcome: OUTCOME_FAILURE, + signal: SIGNAL_FAILURE, + entryIds: ids, + rationale: `The user's next prompt corrected this Claude Code turn; the reply had used ${ids.length} ${ids.length === 1 ? "memory" : "memories"} (memory-term-echo/v2-entry).` + }; +} +function correctionIdempotencyKey(runId, promptId) { + return `cc-correction-${str2(runId)}-${str2(promptId)}`; +} +function correctionRequest(o) { + return { + ...outcomeRequest(o), + idempotency_key: correctionIdempotencyKey(o.runId, o.promptId) }; } function outcomeIdempotencyKey(runId, promptId) { @@ -933,17 +1011,40 @@ function outcomeRequest(o) { idempotency_key: outcomeIdempotencyKey(o.runId, o.promptId) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str2(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + } + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str2(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject(v)) out[ref] = /** @type {any} */ + v; + } + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -1063,9 +1164,9 @@ function scrub(text, count) { } function entropy(s) { if (s === null || s === void 0) return 0; - const str4 = typeof s === "string" ? s : String(s); - if (str4.length === 0) return 0; - const buf = Buffer.from(str4, "utf8"); + const str5 = typeof s === "string" ? s : String(s); + if (str5.length === 0) return 0; + const buf = Buffer.from(str5, "utf8"); const n = buf.length; if (n === 0) return 0; const counts = new Uint32Array(256); @@ -1327,7 +1428,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1397,7 +1498,7 @@ async function postOutcome(cfg, req, opts = {}) { req, "reference_id", "postOutcome", - 'pass "global" for run-level attribution and put the real ids in entry_ids[] (\xA71.3)' + 'pass "global" for run-level attribution and put the real ids in entry_ids[]' ) ); if (bad) return refuse(cfg, started, bad, { route: ROUTES.outcome }); @@ -1426,7 +1527,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1517,7 +1618,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireItems(req) { const items = req && typeof req === "object" ? req.items : void 0; @@ -2017,7 +2118,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -2205,7 +2306,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2401,22 +2502,158 @@ function safeCwd2() { } } -// lib/spool.mjs +// lib/scorecard-log.mjs import { closeSync as closeSync3, - existsSync as existsSync6, - linkSync, + fstatSync as fstatSync2, openSync as openSync3, readdirSync as readdirSync3, readFileSync as readFileSync7, - renameSync as renameSync4, + readSync as readSync2, statSync as statSync6, + writeSync as writeSync4 +} from "node:fs"; +import { dirname as dirname6, join as join10 } from "node:path"; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join10(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function readScoreRows(cfg, sessionId, opts = {}) { + const p = scorecardPath(cfg, sessionId); + if (!p) return []; + return readRowsAt(p, opts); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync6(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync3(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync2(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync3(fd); + } + partialHead = true; + } else { + text = readFileSync7(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} + +// lib/scorecard.mjs +function previousTurn(rows, currentPromptId) { + const list2 = Array.isArray(rows) ? rows.filter(isObject5) : []; + const current = str3(currentPromptId); + const prompts = /* @__PURE__ */ new Map(); + const order = []; + const turns = /* @__PURE__ */ new Map(); + const clears = []; + list2.forEach((row, pos) => { + if (row.kind === "start" && str3(row.source) === "clear") clears.push(pos); + const id2 = str3(row.prompt_id); + if (!id2 || !PROMPT_KINDS.has(row.kind)) return; + if (!prompts.has(id2)) { + prompts.set(id2, markOf(pos)); + order.push(id2); + } + const p = ( + /** @type {PromptMark} */ + prompts.get(id2) + ); + if (row.kind === "prompt") flagPrompt(p, row); + if (row.kind === "turn") turns.set(id2, row); + }); + const at = order.indexOf(current); + const before = (at >= 0 ? order.slice(0, at) : order).filter((id2) => !prompts.get(id2)?.slash); + const id = before[before.length - 1]; + if (!id) return null; + const from = num3(prompts.get(id)?.pos); + const to = at >= 0 ? num3(prompts.get(current)?.pos) : Infinity; + return { promptId: id, turn: turns.get(id) ?? null, afterClear: clears.some((c) => c > from && c < to) }; +} +function correctionTargets(rows) { + const list2 = Array.isArray(rows) ? rows.filter(isObject5) : []; + const out = []; + const seen = /* @__PURE__ */ new Set(); + list2.forEach((row, i) => { + if (row.kind !== "prompt" || row.correction !== true || row.slash === true) return; + const prev = previousTurn(list2.slice(0, i), str3(row.prompt_id)); + const turn = prev?.turn; + if (!prev || !turn || prev.afterClear) return; + const used = Array.isArray(turn.used_refs) && turn.used_refs.some((r) => typeof r === "string" && r); + const runId = str3(turn.run_id); + const key = `${runId} +${prev.promptId}`; + if (!used || !runId || seen.has(key)) return; + seen.add(key); + out.push({ runId, promptId: prev.promptId }); + }); + return out; +} +var PROMPT_KINDS = /* @__PURE__ */ new Set(["prompt", "shown", "turn"]); +function markOf(pos) { + return { slash: false, correction: false, pos, flagged: false }; +} +function flagPrompt(p, row) { + if (p.flagged) return; + p.flagged = true; + p.slash = row.slash === true; + p.correction = row.correction === true; +} +function isObject5(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} +function str3(v) { + return typeof v === "string" ? v : ""; +} +function num3(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +// lib/spool.mjs +import { + closeSync as closeSync4, + existsSync as existsSync6, + linkSync, + openSync as openSync4, + readdirSync as readdirSync4, + readFileSync as readFileSync8, + renameSync as renameSync4, + statSync as statSync7, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4, - writeSync as writeSync4 + writeSync as writeSync5 } from "node:fs"; import { createHash as createHash4, randomBytes } from "node:crypto"; -import { join as join10 } from "node:path"; +import { join as join11 } from "node:path"; var DRAIN_LOCK_TTL_MS = 6e4; var FLUSH_LEASE_TTL_MS = 9e4; var DEFAULT_MAX = 32; @@ -2426,7 +2663,7 @@ function batchIdempotencyKey(runId, items) { return `cc-batch-${digest}`; } function spoolDir(cfg, runId) { - return join10(runDir(cfg, runId), "spool"); + return join11(runDir(cfg, runId), "spool"); } function stampOf(dir, name) { const m = /^(\d{10,})-/.exec(name); @@ -2435,7 +2672,7 @@ function stampOf(dir, name) { if (Number.isFinite(n)) return n; } try { - return statSync6(join10(dir, name)).mtimeMs; + return statSync7(join11(dir, name)).mtimeMs; } catch { return 0; } @@ -2443,7 +2680,7 @@ function stampOf(dir, name) { function orderedNames(dir) { let entries; try { - entries = readdirSync3(dir, { withFileTypes: true }); + entries = readdirSync4(dir, { withFileTypes: true }); } catch { return []; } @@ -2456,10 +2693,10 @@ function readBatch(cfg, runId, max = DEFAULT_MAX) { const dir = spoolDir(cfg, runId); for (const name of orderedNames(dir)) { if (out.length >= limit) break; - const path = join10(dir, name); + const path = join11(dir, name); let raw; try { - raw = readFileSync7(path, "utf8"); + raw = readFileSync8(path, "utf8"); } catch { continue; } @@ -2498,14 +2735,14 @@ function stamp(pid, ts) { } function readLock(lockPath) { try { - const raw = readFileSync7(lockPath, "utf8"); + const raw = readFileSync8(lockPath, "utf8"); const j = JSON.parse(raw); if (!j || typeof j !== "object") return null; const pid = Number(j.pid); let ts = Number(j.ts); if (!Number.isFinite(ts)) { try { - ts = statSync6(lockPath).mtimeMs; + ts = statSync7(lockPath).mtimeMs; } catch { return null; } @@ -2531,7 +2768,7 @@ function acquireDrainLock(cfg, runId) { try { const dir = runDir(cfg, runId); if (!ensureDir(dir)) return null; - const lockPath = join10(dir, "drain.lock"); + const lockPath = join11(dir, "drain.lock"); const held = create(lockPath); if (held) return { path: lockPath, runId: String(runId ?? ""), pid: process.pid, ts: held }; const owner = readLock(lockPath); @@ -2569,16 +2806,16 @@ function create(lockPath) { ) return 0; let fd; try { - fd = openSync3(lockPath, "wx"); + fd = openSync4(lockPath, "wx"); } catch { return 0; } try { - writeSync4(fd, body); + writeSync5(fd, body); } catch { } finally { try { - closeSync3(fd); + closeSync4(fd); } catch { } } @@ -2605,7 +2842,7 @@ function acquireFlushLease(cfg, runId, name) { const safe = safeSegment(name); const dir = runDir(cfg, runId); if (!safe || !ensureDir(dir)) return open; - const lockPath = join10(dir, `flush-${safe}.lock`); + const lockPath = join11(dir, `flush-${safe}.lock`); const held = create(lockPath); if (held) return { path: lockPath, runId: String(runId ?? ""), pid: process.pid, ts: held }; if (!existsSync6(lockPath)) return open; @@ -2635,7 +2872,7 @@ function claimHeld(cfg, runId, name) { try { const safe = safeSegment(name); if (!safe) return false; - return existsSync6(join10(runDir(cfg, runId), `${safe}.marker`)); + return existsSync6(join11(runDir(cfg, runId), `${safe}.marker`)); } catch { return false; } @@ -2646,10 +2883,10 @@ function claimOnce(cfg, runId, name) { if (!safe) return true; const dir = runDir(cfg, runId); ensureDir(dir); - const marker = join10(dir, `${safe}.marker`); + const marker = join11(dir, `${safe}.marker`); let fd; try { - fd = openSync3(marker, "wx"); + fd = openSync4(marker, "wx"); } catch (err) { if ( /** @type {any} */ @@ -2658,11 +2895,11 @@ function claimOnce(cfg, runId, name) { return true; } try { - writeSync4(fd, stamp(process.pid, Date.now())); + writeSync5(fd, stamp(process.pid, Date.now())); } catch { } finally { try { - closeSync3(fd); + closeSync4(fd); } catch { } } @@ -2676,7 +2913,7 @@ function spoolStats(cfg, runId) { const dir = spoolDir(cfg, runId); let entries; try { - entries = readdirSync3(dir, { withFileTypes: true }); + entries = readdirSync4(dir, { withFileTypes: true }); } catch { return { count: 0, oldestMs: 0 }; } @@ -2761,6 +2998,10 @@ await runHook("session-end", { runId, agentId, budget: () => budgetFor(OUTCOME_MS, REFLECT_MS / 2) + }) + await flushCorrections(cfg, { + sessionId, + agentId, + budget: () => budgetFor(OUTCOME_MS, REFLECT_MS / 2) }); const marker = readMarker(cfg, runId); const priorIngested = numOr2(marker.captured?.ingested, 0); @@ -2888,14 +3129,14 @@ async function drainInline(cfg, o) { idempotency_key: batchIdempotencyKey(o.runId, items), parallel: true, items, - ...str3(cfg.userId) ? { user_id: str3(cfg.userId) } : {} + ...str4(cfg.userId) ? { user_id: str4(cfg.userId) } : {} }, { timeoutMs: Math.max(1, Math.min(intOr(cfg.timeoutMs, 4e3), o.deadline - Date.now())) }); batches++; if (!res.ok) { log(cfg, "warn", `session-end: ingest failed (${res.state}); items stay spooled`, { run_id: o.runId, status: res.status ?? 0, - error: str3(res.error).slice(0, 300) + error: str4(res.error).slice(0, 300) }); failed = true; break; @@ -2924,16 +3165,16 @@ async function drainInline(cfg, o) { } function recordJob(cfg, runId, body, n) { try { - const jobId = str3(body?.job_id); + const jobId = str4(body?.job_id); if (!jobId) return; - const p = join11(runDir(cfg, runId), "jobs.json"); + const p = join12(runDir(cfg, runId), "jobs.json"); const prev = readJson(p, []); const arr = Array.isArray(prev) ? prev.filter((e) => !!e && typeof e === "object") : []; arr.push({ job_id: jobId, at: Date.now(), items: n, - status: str3(body?.status), + status: str4(body?.status), deduplicated: body?.deduplicated === true }); writeJsonAtomic(p, arr.slice(-JOBS_KEEP)); @@ -2944,19 +3185,19 @@ async function flushOutcomes(cfg, o) { if (!implicitOutcomesEnabled(cfg)) return 0; let flushed = 0; try { - const dir = join11(runDir(cfg, o.runId), "turns"); + const dir = join12(runDir(cfg, o.runId), "turns"); let names = []; try { - names = readdirSync4(dir).filter((f) => f.endsWith(".json")); + names = readdirSync5(dir).filter((f) => f.endsWith(".json")); } catch { return 0; } for (const name of names.slice(0, MAX_TURN_FLUSH)) { const budget = o.budget(); if (budget <= 0) break; - const p = join11(dir, name); + const p = join12(dir, name); const turn = readJson(p, null); - if (!isObject5(turn) || turn.outcome_pending !== true) continue; + if (!isObject6(turn) || turn.outcome_pending !== true) continue; const decision = decideOutcome(turn); if (!decision.post) { if (decision.reason === "attempts_exhausted") { @@ -2964,7 +3205,7 @@ async function flushOutcomes(cfg, o) { } continue; } - const promptId = str3(turn.prompt_id) || name.replace(/\.json$/, ""); + const promptId = str4(turn.prompt_id) || name.replace(/\.json$/, ""); const attempts = numOr2(turn.outcome_attempts, 0); writeJsonAtomic(p, { ...turn, outcome_attempts: attempts + 1 }); const res = await postOutcome( @@ -3005,6 +3246,48 @@ async function flushOutcomes(cfg, o) { } return flushed; } +async function flushCorrections(cfg, o) { + if (!implicitOutcomesEnabled(cfg)) return 0; + let sent = 0; + try { + const targets = correctionTargets(readScoreRows(cfg, o.sessionId)).slice(-MAX_TURN_FLUSH); + for (const { runId, promptId } of targets) { + const budget = o.budget(); + if (budget <= 0) break; + const p = join12(runDir(cfg, runId), "turns", `${safeSegment(promptId)}.json`); + const turn = readJson(p, null); + const decision = decideCorrection(turn); + if (!decision.post) continue; + const res = await postOutcome( + cfg, + correctionRequest({ runId, agentId: o.agentId, promptId, decision }), + { timeoutMs: budget } + ); + if (!res.ok) { + log(cfg, "info", `session-end: correction flush failed (${res.state})`, { run_id: runId, prompt_id: promptId }); + continue; + } + sent++; + const fresh2 = readJson(p, turn); + writeJsonAtomic(p, { ...isObject6(fresh2) ? fresh2 : turn, correction_sent_at: Date.now() }); + appendLedger(resolveDataDir(cfg), runId, { + v: 1, + kind: "outcome", + at: Date.now(), + run_id: runId, + prompt_id: promptId, + outcome: String(decision.outcome ?? ""), + signal: numOr2(decision.signal, 0), + entry_ids_n: Array.isArray(decision.entryIds) ? decision.entryIds.length : 0, + attempts: 1, + correction: true + }); + } + } catch (err) { + log(cfg, "warn", `session-end: correction flush skipped \u2014 ${messageOf2(err)}`, { session_id: o.sessionId }); + } + return sent; +} async function maybeReflect(cfg, o) { const idle = { attempted: false, status: "skipped", lessons: 0, at: 0, attempts: 0, error: "" }; if (cfg.reflectOnEnd === false) { @@ -3047,8 +3330,7 @@ async function maybeReflect(cfg, o) { const reflectBody = { run_id: o.runId, include_linked_runs: false, - // `include_step_outcomes` folds outcome signals into the evidence - // (`control.proto`) — the NEGATIVE ones produce the highest-value lessons. + // `include_step_outcomes`: the outcomes posted above are part of what reflect reads. include_step_outcomes: true, // `last_n_items` bounds the evidence to the most recent items of the run, of every kind // and including those outcomes. A session end is the tail of its run, so that bound is @@ -3056,7 +3338,7 @@ async function maybeReflect(cfg, o) { last_n_items: REFLECT_LAST_N // `record: false`, because a deadline this client chose is not evidence about the server. // `lib/http.mjs` already exempts callers who dial *tighter* than the configured default; - // this one is the mirror image and the exemption misses it — the reflect is LLM-backed + // this one is the mirror image and the exemption misses it — the reflect is slow // and dials deliberately wide, so its abort would be filed as `not_responding` against an // instance that was still composing an answer. Five of those inside the window open the // breaker, and the breaker gates the ingest *drain*: a merely slow reflection would @@ -3100,7 +3382,7 @@ async function maybeReflect(cfg, o) { run_id: o.runId, status: res.status ?? 0, attempts, - error: str3(res.error).slice(0, 300) + error: str4(res.error).slice(0, 300) } ); return { @@ -3109,10 +3391,10 @@ async function maybeReflect(cfg, o) { lessons: 0, at: Date.now(), attempts, - error: str3(res.error) + error: str4(res.error) }; } - const body = isObject5(res.body) ? res.body : {}; + const body = isObject6(res.body) ? res.body : {}; const stored = Number.isFinite(Number(body.lessons_stored)) ? Math.max(0, Math.trunc(Number(body.lessons_stored))) : Array.isArray(body.lessons) ? body.lessons.length : 0; log( cfg, @@ -3133,10 +3415,10 @@ function breakerOpen(cfg) { return false; } } -function isObject5(v) { +function isObject6(v) { return !!v && typeof v === "object" && !Array.isArray(v); } -function str3(v) { +function str4(v) { return typeof v === "string" ? v.trim() : ""; } function numOr2(v, d) { diff --git a/integrations/claude-code/hooks/dist/impl/session-resume.mjs b/integrations/claude-code/hooks/dist/impl/session-resume.mjs index 9894483..19e76ab 100755 --- a/integrations/claude-code/hooks/dist/impl/session-resume.mjs +++ b/integrations/claude-code/hooks/dist/impl/session-resume.mjs @@ -183,7 +183,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -303,6 +303,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -376,6 +386,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1044,6 +1056,13 @@ function safeConfig() { } } +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ "mental_models", @@ -1396,7 +1415,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1469,7 +1488,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1560,7 +1579,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1730,7 +1749,8 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function failure(state, error, rung) { @@ -1744,6 +1764,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; diff --git a/integrations/claude-code/hooks/dist/impl/session-start.mjs b/integrations/claude-code/hooks/dist/impl/session-start.mjs index e42bcf5..1355788 100644 --- a/integrations/claude-code/hooks/dist/impl/session-start.mjs +++ b/integrations/claude-code/hooks/dist/impl/session-start.mjs @@ -1,7 +1,7 @@ // mubit-memory — generated by esbuild. Do not edit; edit hooks/src or lib. // hooks/src/session-start.mjs -import { join as join11 } from "node:path"; +import { join as join12 } from "node:path"; // lib/breaker.mjs import { createHash } from "node:crypto"; @@ -439,7 +439,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -559,6 +559,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -632,6 +642,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1417,7 +1429,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1546,7 +1558,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1689,7 +1701,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -2056,8 +2068,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -2146,7 +2158,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -2209,6 +2221,117 @@ function updateMarker(cfg, runId, patch = {}) { } } +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} +function stripHandles(text) { + return String(text ?? "").replace(TAG_RE, " "); +} + +// lib/assemble.mjs +var SECTION_KEYS = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "archive_blocks", + "handoffs", + "feedback", + "facts", + "observations", + "working_memory", + "traces", + "goals", + "checkpoints", + "logs", + "other" +]); +var EMISSION_ORDER = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "facts", + "observations", + "working_memory", + "traces", + "goals" +]); +var RENDER_ORDER = Object.freeze([ + ...EMISSION_ORDER, + ...SECTION_KEYS.filter((k) => !EMISSION_ORDER.includes(k)) +]); +var SECTION_BY_ENTRY_TYPE = Object.freeze({ + mental_model: "mental_models", + rule: "active_rules", + lesson: "lessons", + fact: "facts", + observation: "observations", + working_memory: "working_memory", + goal: "working_memory", + trace: "traces", + tool_output: "traces", + tool_input: "traces", + task_result: "traces", + step_outcome: "traces", + archive_block: "archive_blocks", + checkpoint: "checkpoints", + handoff: "handoffs", + feedback: "feedback" +}); +var HEADINGS = Object.freeze({ + mental_models: "Mental models", + active_rules: "Active rules", + lessons: "Lessons", + archive_blocks: "Archive blocks", + handoffs: "Handoffs", + feedback: "Feedback", + facts: "Facts", + observations: "Observations", + working_memory: "Working memory", + traces: "Traces", + goals: "Goals", + checkpoints: "Checkpoints", + logs: "Logs", + other: "Other" +}); +var CHARS_PER_TOKEN = 4; +var MAX_POINTER_CHARS = 64; +var MIN_POINTER_CHARS = 24; +function estimateTokens(text) { + if (typeof text !== "string" || text.length === 0) return 0; + return Math.ceil(text.length / CHARS_PER_TOKEN); +} +function firstClause(text) { + const s = typeof text === "string" ? text.trim() : ""; + if (!s) return ""; + const stop = s.search(/[.;!?]/); + let end = stop > 0 ? Math.min(stop, MAX_POINTER_CHARS) : MAX_POINTER_CHARS; + if (end < MIN_POINTER_CHARS) end = Math.min(s.length, MAX_POINTER_CHARS); + const clause = s.slice(0, end).trim() || s.slice(0, MAX_POINTER_CHARS).trim(); + return clause.length < s.length ? `${clause}\u2026` : clause; +} + // lib/rules.mjs import { join as join9 } from "node:path"; var RULES_FILE = "rules.json"; @@ -2297,11 +2420,189 @@ function isObject2(v) { return !!v && typeof v === "object" && !Array.isArray(v); } +// lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync2, + readFileSync as readFileSync5, + readSync, + statSync as statSync4, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname4, join as join10 } from "node:path"; +var SCORE_LOG_VERSION = 1; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join10(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname4(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} + +// lib/terms.mjs +var TERM_RE = /[A-Za-z][A-Za-z0-9_]{3,23}/g; +var TERM_STOPWORDS = /* @__PURE__ */ new Set([ + "about", + "after", + "again", + "against", + "also", + "always", + "another", + "because", + "been", + "before", + "being", + "between", + "both", + "called", + "does", + "doing", + "done", + "each", + "else", + "even", + "ever", + "every", + "from", + "have", + "here", + "html", + "http", + "https", + "into", + "just", + "like", + "made", + "make", + "many", + "more", + "most", + "much", + "must", + "need", + "never", + "next", + "once", + "only", + "other", + "over", + "part", + "same", + "says", + "send", + "sent", + "should", + "since", + "some", + "such", + "take", + "than", + "that", + "their", + "them", + "then", + "there", + "these", + "they", + "this", + "those", + "through", + "thing", + "time", + "under", + "until", + "very", + "want", + "well", + "were", + "what", + "when", + "where", + "which", + "while", + "will", + "with", + "without", + "would", + "your" +]); +var MAX_PROMPT_SCAN = 16 * 1024; +var MAX_ANSWER_SCAN = 64 * 1024; +var MAX_ENTRY_TERMS = 32; +var MAX_TITLE_CHARS = 48; +var PLACEHOLDER_RE = /\[REDACTED:[^\]]*\]/gi; +function entryTerms(cfg, text, promptTerms = /* @__PURE__ */ new Set(), max = MAX_ENTRY_TERMS) { + try { + const raw = stripHandles(String(text ?? "")); + if (!raw.trim()) return []; + const scrubbed = String(redactText(raw, cfg, "output")?.text ?? "").replace(PLACEHOLDER_RE, " "); + const skip = promptTerms instanceof Set ? promptTerms : /* @__PURE__ */ new Set(); + const cap = Number.isFinite(max) && max > 0 ? Math.trunc(max) : MAX_ENTRY_TERMS; + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of scrubbed.matchAll(TERM_RE)) { + const t = m[0].toLowerCase(); + if (seen.has(t) || skip.has(t) || TERM_STOPWORDS.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= cap) break; + } + return out; + } catch { + return []; + } +} +function entryTitle(cfg, text, max = MAX_TITLE_CHARS) { + try { + const whole = String(text ?? "").replace(/\s+/g, " ").trim(); + let clause = firstClause(whole); + if (clause.endsWith("\u2026") && whole.replace(/[.;!?]+$/, "") === clause.slice(0, -1)) { + clause = clause.slice(0, -1); + } + const t = String(redactText(clause, cfg, "output")?.text ?? "").trim(); + const cap = Number.isFinite(max) && max > 1 ? Math.trunc(max) : MAX_TITLE_CHARS; + return t.length > cap ? `${t.slice(0, cap - 1).trimEnd()}\u2026` : t; + } catch { + return ""; + } +} + // lib/runid.mjs import { spawnSync } from "node:child_process"; import { createHash as createHash3 } from "node:crypto"; -import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync4 } from "node:fs"; -import { basename as basename2, dirname as dirname4, join as join10, resolve as resolve3 } from "node:path"; +import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync5 } from "node:fs"; +import { basename as basename2, dirname as dirname5, join as join11, resolve as resolve3 } from "node:path"; var STRATEGIES = /* @__PURE__ */ new Set(["per-directory", "git-branch", "per-conversation", "static"]); var DEFAULT_STRATEGY = "per-directory"; var SOURCES = /* @__PURE__ */ new Set(["startup", "resume", "clear", "compact", "fork"]); @@ -2412,7 +2713,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2498,7 +2799,7 @@ function normaliseRecord(record) { return out; } function sessionPath(file) { - return join10(dataDir({}), "sessions", `${file}.json`); + return join11(dataDir({}), "sessions", `${file}.json`); } function sessionFileName(sessionId) { const raw = typeof sessionId === "string" ? sessionId.trim() : ""; @@ -2516,7 +2817,7 @@ function usableDir(v) { const s = typeof v === "string" ? v.trim() : ""; if (!s) return ""; try { - return statSync4(s).isDirectory() ? s : ""; + return statSync5(s).isDirectory() ? s : ""; } catch { return ""; } @@ -2546,8 +2847,8 @@ function hasGitDir(start) { try { let cur = resolve3(start); for (let i = 0; i < 24; i++) { - if (existsSync5(join10(cur, ".git"))) return true; - const up = dirname4(cur); + if (existsSync5(join11(cur, ".git"))) return true; + const up = dirname5(cur); if (up === cur) return false; cur = up; } @@ -2635,12 +2936,14 @@ await runHook("session-start", { agentId = deriveAgentId(payload); } catch (err) { log(cfg, "error", `session-start: no usable run id (${messageOf2(err)})`); + appendStart(cfg, payload, []); return {}; } const statusLineHint2 = probeStatusLine(cfg); if (!isConfigured(cfg)) { updateMarker(cfg, runId, { mode: cfg.mode, state: "unconfigured", cold_start_until: 0, last_error: "" }); log(cfg, "debug", "session-start: no endpoint configured", { run_id: runId }); + appendStart(cfg, payload, []); return { hookSpecificOutput: { hookEventName: "SessionStart", @@ -2669,6 +2972,7 @@ await runHook("session-start", { }; if (!warming) out.systemMessage = `mubit: offline (${state})${DOT}capture buffered`; if (statusLineHint2) out.systemMessage = statusLineHint2; + appendStart(cfg, payload, []); return out; } let authError = ""; @@ -2728,6 +3032,7 @@ await runHook("session-start", { } }; out.systemMessage = statusLineHint2 || `mubit: auth failed${DOT}capture buffered`; + appendStart(cfg, payload, []); return out; } updateMarker(cfg, runId, { @@ -2749,18 +3054,49 @@ await runHook("session-start", { const anchor = src === "compact" ? latestCheckpointId(cfg, runId) : ""; const standing = lessonsPartial ? "global lessons: partial listing" : `${lessons.length} global lesson${lessons.length === 1 ? "" : "s"}`; const summary = `mubit: ${cfg.mode}${DOT}run ${runId}${DOT}${standing}`; + appendStart(cfg, payload, lessons); return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: steerBlock(cfg, runId, lessons, anchor, lessonsPartial) }, - // §16.2's hint fires once, ever, per install, so on that one session it *takes* the + // The status-line hint fires once, ever, per install, so on that one session it *takes* the // line rather than being appended to it: `systemMessage` is one line by contract, and // the run and mode it would displace are already named in the steer block above. systemMessage: statusLineHint2 || summary }; } }); +function lessonLines(lessons) { + return lessons.map((l) => { + const tag = handleTag(l.id); + return `- ${tag ? `${tag} ` : ""}[${l.type}] ${l.content}`; + }); +} +function appendStart(cfg, payload, lessons) { + try { + if (cfg.capture === false) return; + const sessionId = typeof payload?.session_id === "string" ? payload.session_id.trim() : ""; + if (!sessionId) return; + const withIds = lessons.filter((l) => l.id); + const rows = {}; + for (const l of withIds) { + rows[l.id] = { + title: entryTitle(cfg, l.content), + terms: entryTerms(cfg, l.content, /* @__PURE__ */ new Set()), + handle: handleFor(l.id) + }; + } + appendScoreRow(cfg, sessionId, { + kind: "start", + source: sourceOf(payload) || "startup", + lessons: rows, + refs: withIds.map((l) => l.id), + tokens: estimateTokens(lessonLines(lessons).join("\n")) + }); + } catch { + } +} function spawnResume(cfg, payload, runId, agentId, src) { try { if (!cfg.resumeBlock || !cfg.recall) return; @@ -2794,6 +3130,9 @@ function steerBlock(cfg, runId, lessons, anchor = "", partial = false) { "Do search when the injected memory falls short: mubit_recall for a topic, mubit_diagnose when a command has failed, mubit_dereference for a reference_id you already hold.", `Save what you learn with mubit_learned, and credit what helped with mubit_outcome. ${skill("remember")} and ${skill("recall")} are the explicit forms.` ); + if (cfg.capture && cfg.outcomeReview !== "off") { + lines.push("Each memory line starts with a short id in brackets, like [m7k2q]: pass it in entry_ids of mubit_outcome to credit or fault that entry."); + } } if (anchor) { lines.push( @@ -2805,7 +3144,7 @@ function steerBlock(cfg, runId, lessons, anchor = "", partial = false) { if (lessons.length || partial) { lines.push("", "## Standing lessons (global)"); lines.push("Learned from earlier work \u2014 they may be out of date, so verify before relying on one."); - for (const l of lessons) lines.push(`- [${l.type}] ${l.content}`); + lines.push(...lessonLines(lessons)); if (partial) { lines.push(`This set may be incomplete \u2014 it was read from a listing with more than this page in it. Ask ${skill("recall")} if a constraint seems to be missing.`); } @@ -2847,7 +3186,7 @@ function armColdStart(cfg) { const grace = Math.max(0, intOr(cfg.coldStartGraceMs, 0)); if (grace <= 0) return 0; try { - const path = join11(dataDir(cfg), "coldstart", `${endpointHash(cfg)}.json`); + const path = join12(dataDir(cfg), "coldstart", `${endpointHash(cfg)}.json`); const stored = readJson(path, null); const until = stored && typeof stored === "object" ? intOr(stored.until, 0) : 0; if (until > 0) return until; @@ -2860,7 +3199,7 @@ function armColdStart(cfg) { } function latestCheckpointId(cfg, runId) { try { - const path = join11(resolveDataDir(cfg), "runs", safeSegment2(runId), "checkpoints.json"); + const path = join12(resolveDataDir(cfg), "runs", safeSegment2(runId), "checkpoints.json"); const stored = readJson(path, []); const list2 = Array.isArray(stored) ? stored : Array.isArray(stored?.checkpoints) ? stored.checkpoints : stored?.items; if (!Array.isArray(list2)) return ""; @@ -2907,7 +3246,7 @@ function readLessons(body) { function probeStatusLine(cfg) { try { if (cfg.statusLine === false) return ""; - const p = join11(dataDir(cfg), LIVENESS_FILE); + const p = join12(dataDir(cfg), LIVENESS_FILE); const now = Date.now(); const rec = readJson(p, null); if (!rec || typeof rec !== "object" || Array.isArray(rec)) { @@ -2932,7 +3271,7 @@ function probeStatusLine(cfg) { notified_at: nag ? now : notifiedAt }); if (!nag) return ""; - log(cfg, "info", "session-start: status line never invoked; emitting the one-time \xA716.2 hint"); + log(cfg, "info", "session-start: status line never invoked; emitting the one-time status-line hint"); return statusLineHint(); } catch { return ""; diff --git a/integrations/claude-code/hooks/dist/impl/stage-prompt.mjs b/integrations/claude-code/hooks/dist/impl/stage-prompt.mjs index e8c1134..529d3de 100644 --- a/integrations/claude-code/hooks/dist/impl/stage-prompt.mjs +++ b/integrations/claude-code/hooks/dist/impl/stage-prompt.mjs @@ -2,8 +2,8 @@ // hooks/src/stage-prompt.mjs import { randomUUID } from "node:crypto"; -import { readdirSync as readdirSync4 } from "node:fs"; -import { join as join8 } from "node:path"; +import { readdirSync as readdirSync5 } from "node:fs"; +import { join as join9 } from "node:path"; // lib/config.mjs import { createHash } from "node:crypto"; @@ -195,7 +195,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -301,6 +301,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -374,6 +384,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -504,6 +516,39 @@ function safeCwd() { } } +// lib/correction.mjs +var SCAN_CHARS = 300; +var LEADING_NO = /^(?:no|nope)(?:\s*[,.!;:—–-]|\s*$)/; +var POLITE_NO = /^(?:no|nope)[\s,.!]*(?:problem|worries|thanks|thank you|need|rush|biggie)\b/; +var BARE_NO = /^(?:no|nope)[\s.!]*$/; +var PHRASES = [ + /^(?:wrong|incorrect)\b/, + /\b(?:that'?s|that is|this is|it'?s|it is)\s+(?:wrong|incorrect|not right|not it|not what i (?:asked|wanted|meant))\b/, + /\bnot what i (?:asked|wanted|meant)\b/, + /\b(?:you|it|this|that) broke\b/, + /\bstill (?:failing|fails|broken|erroring|crashing|not working|(?:doesn'?t|does not|isn'?t|is not) work(?:ing)?)\b/, + /\b(?:doesn'?t|does not|didn'?t|did not) work\b/, + /\b(?:revert|undo) (?:that|this|it|the (?:last|previous))\b/, + /\broll back (?:that|this|it|the)\b/, + /\byou misunderstood\b/, + /\byou(?:'ve| have)? got it wrong\b/ +]; +function isCorrection(prompt, opts = {}) { + try { + if (typeof prompt !== "string") return false; + const trimmed = prompt.trim(); + if (!trimmed || trimmed.startsWith("/")) return false; + const text = trimmed.replace(/```[\s\S]*?(?:```|$)/g, " ").replace(/"[^"\n]*"|“[^”\n]*”|`[^`\n]*`/g, " ").replace(/[‘’]/g, "'").trim().slice(0, SCAN_CHARS).toLowerCase(); + if (!text) return false; + const asked = opts?.lastReplyEndedWithQuestion === true; + if (BARE_NO.test(text)) return !asked; + if (!asked && LEADING_NO.test(text) && !POLITE_NO.test(text)) return true; + return PHRASES.some((re) => re.test(text)); + } catch { + return false; + } +} + // lib/hook.mjs import { spawn } from "node:child_process"; import { @@ -628,9 +673,9 @@ function scrub(text, count) { } function entropy(s) { if (s === null || s === void 0) return 0; - const str2 = typeof s === "string" ? s : String(s); - if (str2.length === 0) return 0; - const buf = Buffer.from(str2, "utf8"); + const str4 = typeof s === "string" ? s : String(s); + if (str4.length === 0) return 0; + const buf = Buffer.from(str4, "utf8"); const n = buf.length; if (n === 0) return 0; const counts = new Uint32Array(256); @@ -1103,6 +1148,19 @@ function safeConfig() { } } +// lib/outcome.mjs +var SILENCED_MODES = /* @__PURE__ */ new Set(["off", "explicit"]); +function implicitOutcomesEnabled(cfg) { + const mode = str2(cfg && typeof cfg === "object" ? ( + /** @type {any} */ + cfg.outcomeMode + ) : "").toLowerCase(); + return !SILENCED_MODES.has(mode); +} +function str2(v) { + return typeof v === "string" ? v.trim() : ""; +} + // lib/runid.mjs import { spawnSync } from "node:child_process"; import { createHash as createHash2 } from "node:crypto"; @@ -1217,7 +1275,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -1405,23 +1463,167 @@ function safeCwd2() { } } -// lib/spool.mjs +// lib/scorecard-log.mjs import { closeSync as closeSync2, - existsSync as existsSync6, - linkSync, + fstatSync, openSync as openSync2, readdirSync as readdirSync3, readFileSync as readFileSync6, - renameSync as renameSync3, + readSync, statSync as statSync5, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname5, join as join7 } from "node:path"; +var SCORE_LOG_VERSION = 1; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join7(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname5(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} +function readScoreRows(cfg, sessionId, opts = {}) { + const p = scorecardPath(cfg, sessionId); + if (!p) return []; + return readRowsAt(p, opts); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync5(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync2(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync2(fd); + } + partialHead = true; + } else { + text = readFileSync6(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} + +// lib/scorecard.mjs +function previousTurn(rows, currentPromptId) { + const list2 = Array.isArray(rows) ? rows.filter(isObject3) : []; + const current = str3(currentPromptId); + const prompts = /* @__PURE__ */ new Map(); + const order = []; + const turns = /* @__PURE__ */ new Map(); + const clears = []; + list2.forEach((row, pos) => { + if (row.kind === "start" && str3(row.source) === "clear") clears.push(pos); + const id2 = str3(row.prompt_id); + if (!id2 || !PROMPT_KINDS.has(row.kind)) return; + if (!prompts.has(id2)) { + prompts.set(id2, markOf(pos)); + order.push(id2); + } + const p = ( + /** @type {PromptMark} */ + prompts.get(id2) + ); + if (row.kind === "prompt") flagPrompt(p, row); + if (row.kind === "turn") turns.set(id2, row); + }); + const at = order.indexOf(current); + const before = (at >= 0 ? order.slice(0, at) : order).filter((id2) => !prompts.get(id2)?.slash); + const id = before[before.length - 1]; + if (!id) return null; + const from = num(prompts.get(id)?.pos); + const to = at >= 0 ? num(prompts.get(current)?.pos) : Infinity; + return { promptId: id, turn: turns.get(id) ?? null, afterClear: clears.some((c) => c > from && c < to) }; +} +var PROMPT_KINDS = /* @__PURE__ */ new Set(["prompt", "shown", "turn"]); +function markOf(pos) { + return { slash: false, correction: false, pos, flagged: false }; +} +function flagPrompt(p, row) { + if (p.flagged) return; + p.flagged = true; + p.slash = row.slash === true; + p.correction = row.correction === true; +} +function isObject3(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} +function str3(v) { + return typeof v === "string" ? v : ""; +} +function num(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +// lib/spool.mjs +import { + closeSync as closeSync3, + existsSync as existsSync6, + linkSync, + openSync as openSync3, + readdirSync as readdirSync4, + readFileSync as readFileSync7, + renameSync as renameSync3, + statSync as statSync6, unlinkSync as unlinkSync4, writeFileSync as writeFileSync3, - writeSync as writeSync3 + writeSync as writeSync4 } from "node:fs"; -import { join as join7 } from "node:path"; +import { join as join8 } from "node:path"; function spoolDir(cfg, runId) { - return join7(runDir(cfg, runId), "spool"); + return join8(runDir(cfg, runId), "spool"); } function stampOf(dir, name) { const m = /^(\d{10,})-/.exec(name); @@ -1430,7 +1632,7 @@ function stampOf(dir, name) { if (Number.isFinite(n)) return n; } try { - return statSync5(join7(dir, name)).mtimeMs; + return statSync6(join8(dir, name)).mtimeMs; } catch { return 0; } @@ -1440,7 +1642,7 @@ function spoolStats(cfg, runId) { const dir = spoolDir(cfg, runId); let entries; try { - entries = readdirSync3(dir, { withFileTypes: true }); + entries = readdirSync4(dir, { withFileTypes: true }); } catch { return { count: 0, oldestMs: 0 }; } @@ -1462,7 +1664,8 @@ function spoolStats(cfg, runId) { // hooks/src/stage-prompt.mjs var BUDGET_MS = 250; var MAX_PROMPT_BYTES = 64 * 1024; -var MAX_ID = 128; +var MAX_ID2 = 128; +var LOG_TAIL_BYTES = 64 * 1024; await runHook("stage-prompt", { budgetMs: BUDGET_MS, body: async (payload) => { @@ -1476,19 +1679,20 @@ await runHook("stage-prompt", { return { suppressOutput: true }; } stageTurn(cfg, runId, payload); + if (cfg.capture !== false) scorePrompt(cfg, payload); if (cfg.capture) maybeDrain(cfg, runId, payload); return { suppressOutput: true }; } }); function stageTurn(cfg, runId, payload) { try { - const promptId = safeSegment(turnKey(payload), MAX_ID); + const promptId = safeSegment(turnKey(payload), MAX_ID2); if (!promptId) return false; - const dir = join8(runDir(cfg, runId), "turns"); + const dir = join9(runDir(cfg, runId), "turns"); if (!ensureDir(dir)) return false; - const file = join8(dir, `${promptId}.json`); + const file = join9(dir, `${promptId}.json`); const prev = readJson(file, null); - const base = isObject3(prev) ? prev : {}; + const base = isObject4(prev) ? prev : {}; const { text, truncated } = clampPrompt(payload?.prompt); const next = { ...base, @@ -1496,11 +1700,12 @@ function stageTurn(cfg, runId, payload) { prompt_id: promptId, session_id: typeof payload?.session_id === "string" ? payload.session_id : "", started_at: Number.isFinite(base.started_at) ? base.started_at : Date.now(), - // The other half of the §5.3 race: never overwrite ids `prompt-recall` already staged. + // The other half of the staging race: never overwrite ids `prompt-recall` already staged. recalled: Array.isArray(base.recalled) ? base.recalled : [], turn_number: Number.isFinite(base.turn_number) && base.turn_number > 0 ? base.turn_number : ordinalFor(dir, !!prev) }; if (truncated) next.prompt_truncated = true; + if (typeof base.prompt === "string") next.queued_at = Date.now(); return writeJsonAtomic(file, next); } catch (err) { log(cfg, "warn", `stage-prompt: could not stage the turn (${messageOf(err)})`, { run_id: runId }); @@ -1509,12 +1714,41 @@ function stageTurn(cfg, runId, payload) { } function ordinalFor(dir, mine) { try { - const n = readdirSync4(dir).filter((f) => f.endsWith(".json")).length; + const n = readdirSync5(dir).filter((f) => f.endsWith(".json")).length; return Math.max(1, mine ? n : n + 1); } catch { return 1; } } +function scorePrompt(cfg, payload) { + try { + const sessionId = typeof payload?.session_id === "string" ? payload.session_id : ""; + const promptId = safeSegment(turnKey(payload), MAX_ID2); + if (!sessionId || !promptId) return; + const prompt = typeof payload?.prompt === "string" ? payload.prompt : ""; + const slash = prompt.trim().startsWith("/"); + const rows = readScoreRows(cfg, sessionId, { tailBytes: LOG_TAIL_BYTES }); + if (rows.some((r) => r.kind === "prompt" && r.prompt_id === promptId)) return; + const before = previousTurn(rows, promptId); + const prev = before?.turn ?? null; + const afterClear = before?.afterClear === true; + const correction = !slash && !!prev && !afterClear && isCorrection(prompt, { lastReplyEndedWithQuestion: prev?.ended_with_question === true }); + appendScoreRow(cfg, sessionId, { kind: "prompt", prompt_id: promptId, correction, slash }); + if (!correction || !prev || !implicitOutcomesEnabled(cfg)) return; + const used = Array.isArray(prev.used_refs) ? prev.used_refs.filter((v) => typeof v === "string" && v.trim()) : []; + const prevRun = typeof prev.run_id === "string" ? prev.run_id : ""; + if (!used.length || !prevRun) return; + spawnDetached( + cfg, + "drain", + ["--correct", String(prev.prompt_id), "--run", prevRun], + writePayload(cfg, payload) + ); + log(cfg, "debug", "stage-prompt: correction of the previous turn", { prompt_id: String(prev.prompt_id) }); + } catch (err) { + log(cfg, "warn", `stage-prompt: could not score the prompt (${messageOf(err)})`); + } +} function maybeDrain(cfg, runId, payload) { try { const { count, oldestMs } = spoolStats(cfg, runId); @@ -1530,10 +1764,10 @@ function maybeDrain(cfg, runId, payload) { } } function writePayload(cfg, payload) { - const dir = join8(resolveDataDir(cfg), "tmp"); - const file = join8(dir, `${randomUUID()}.json`); + const dir = join9(resolveDataDir(cfg), "tmp"); + const file = join9(dir, `${randomUUID()}.json`); ensureDir(dir); - writeJsonAtomic(file, isObject3(payload) ? payload : {}); + writeJsonAtomic(file, isObject4(payload) ? payload : {}); return file; } function clampPrompt(v) { @@ -1541,7 +1775,7 @@ function clampPrompt(v) { if (Buffer.byteLength(s, "utf8") <= MAX_PROMPT_BYTES) return { text: s, truncated: false }; return { text: Buffer.from(s, "utf8").subarray(0, MAX_PROMPT_BYTES).toString("utf8"), truncated: true }; } -function isObject3(v) { +function isObject4(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function intOr(v, d) { diff --git a/integrations/claude-code/hooks/dist/impl/subagent-start.mjs b/integrations/claude-code/hooks/dist/impl/subagent-start.mjs index 221fc71..b90578c 100755 --- a/integrations/claude-code/hooks/dist/impl/subagent-start.mjs +++ b/integrations/claude-code/hooks/dist/impl/subagent-start.mjs @@ -2,7 +2,7 @@ // mubit-memory — generated by esbuild. Do not edit; edit hooks/src or lib. // hooks/src/subagent-start.mjs -import { join as join10 } from "node:path"; +import { join as join11 } from "node:path"; // lib/config.mjs import { spawnSync } from "node:child_process"; @@ -195,7 +195,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; var MAX_ENV_TAGS = 8; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); @@ -379,6 +379,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -452,6 +462,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1123,6 +1135,32 @@ function safeConfig() { // lib/pins.mjs import { join as join6 } from "node:path"; +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} + // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ "mental_models", @@ -1217,12 +1255,14 @@ function assembleContext(evidence, opts = {}) { if (!isObject2(e)) continue; const text = oneLine(e.content); if (!text) continue; - const section = sectionFor(str2(e.origin_entry_type) || str2(e.entry_type)); + const type = (str2(e.origin_entry_type) || str2(e.entry_type)).toLowerCase(); + const section = sectionFor(type); if (allowed && !allowed.has(section)) continue; candidates++; const bucket = bySection.get(section) ?? []; bucket.push({ ref: str2(e.reference_id), + type, text, score: finite(e.score, 0), stale: e.is_stale === true, @@ -1238,12 +1278,14 @@ function assembleContext(evidence, opts = {}) { sourceRefIds: [], dropped: 0, pointers: 0, - emptyReason: "no_evidence" + emptyReason: "no_evidence", + entries: [] }; } const parts = []; const sourceRefIds = []; const seenRefs = /* @__PURE__ */ new Set(); + const entries = []; const sections = []; let used = 0; let rendered = 0; @@ -1261,9 +1303,10 @@ function assembleContext(evidence, opts = {}) { if (perSection > 0 && count >= perSection) { continue; } - const full = `- ${item.stale ? "(stale) " : ""}${item.text} + const tag = handleTag(item.ref); + const full = `- ${tag ? `${tag} ` : ""}${item.stale ? "(stale) " : ""}${item.text} `; - const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${item.ref} \u2014 ${firstClause(item.text)} + const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${tag} \u2014 ${firstClause(item.text)} ` : ""; const degraded = !!pointer && pointer.length < full.length; const line = degraded ? pointer : full; @@ -1282,6 +1325,18 @@ function assembleContext(evidence, opts = {}) { seenRefs.add(item.ref); sourceRefIds.push(item.ref); } + if (!item.ref || entries.every((x) => x.ref !== item.ref)) { + entries.push({ + ref: item.ref, + handle: handleFor(item.ref), + section: key, + type: item.type, + text: item.text, + title: firstClause(item.text), + pointer: degraded, + stale: item.stale + }); + } } if (count > 0) sections.push({ section: key, count }); } @@ -1297,7 +1352,8 @@ function assembleContext(evidence, opts = {}) { // A degraded entry is rendered, not dropped: it counts here and nowhere else, so a // reader can tell a block that shrank from a block that lost half its evidence. pointers, - emptyReason: rendered > 0 ? "" : "budget_exhausted" + emptyReason: rendered > 0 ? "" : "budget_exhausted", + entries: rendered > 0 ? entries : [] }; } function firstClause(text) { @@ -1621,7 +1677,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1703,7 +1759,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1794,13 +1850,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -2225,30 +2281,30 @@ async function ladder(cfg, o) { limit: QUERY_LIMIT, entry_types: [...ENTRY_TYPES], include_working_memory: true, - // `env_tags` is accepted on the query route but not on the context route — version-aware - // tag scoring is capability rungs 1-2 gain over rung 3, not something they give up. + // `env_tags` is accepted on the query route but not on the context route — a capability + // rungs 1-2 gain over rung 3, not something they give up. // Tagged from the directory this prompt was sent in, not the one the session launched - // in — the same reason the run id reads the payload. A recall scored against `repo:` - // tags from the wrong repo is worse than one scored against none. + // in — the same reason the run id reads the payload. A recall tagged with the wrong repo + // is worse than an untagged one. env_tags: envTags(cfg, o.projectDir), // `rank_by` is the same trap as `env_tags` above, one field further on. `/context` // accepts no ranking field of ANY kind, // which makes freshness the second capability rungs 1-2 gain over rung 3 rather than // something they give up. What makes it a trap rather than a limitation: turning rung 3 // on (`recallAssemble: "server"`) does not fail, warn, or fall back: it silently reverts - // every recall to the default fusion weights, and "where were we?" quietly goes back to + // every recall to the default ordering, and "where were we?" quietly goes back to // answering with whatever is most similar. Documented in the README's `recallAssemble` // row for the same reason. // // Omitted rather than sent when it resolves to nothing: absent IS `relevance` // server-side, so there is no shape of request this spread cannot express. ...rankBy ? { rank_by: rankBy } : {}, - // §5.2: opting out of the cross-run lesson overlay, and the ONLY field here that is sent - // to make the request cheaper rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. + // Opting out of cross-run lessons, and the ONLY field here that is sent to make the + // request faster rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. // - // Omitted rather than sent as `false` when the overlay is wanted: absent IS `false` - // server-side, and a request log that only ever shows the field when somebody declined - // the lane is easier to read than one where every request carries it. + // Omitted rather than sent as `false` when they are wanted: absent IS `false`, and a + // request log that only ever shows the field when somebody declined them is easier to + // read than one where every request carries it. ...crossRun ? {} : { prefer_current_run: true } }; let denied = readPolicyDenial(cfg); @@ -2263,7 +2319,7 @@ async function ladder(cfg, o) { if (res2.status === 403) { cachePolicyDenial(cfg); if (cfg.recallFallback !== "agent_routed") { - log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to pay an LLM call per prompt instead.', { run_id: o.runId }); + log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to accept a slower recall instead.', { run_id: o.runId }); return empty(1, "policy_denied"); } log( @@ -2328,7 +2384,8 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function fromEvidence(cfg, responseBody, rung, o) { @@ -2350,9 +2407,10 @@ function fromEvidence(cfg, responseBody, rung, o) { dropped: a.dropped, pointers: a.pointers, emptyReason: a.emptyReason, - // §4.10/§5.5: a degraded entry is still in here. Dropping a repeat would break + // A degraded entry is still in here. Dropping a repeat would break // attribution for exactly the memories that are helping most. - refIds: a.sourceRefIds + refIds: a.sourceRefIds, + entries: a.entries }; } function tokenBudgetOf(cfg, o) { @@ -2380,7 +2438,8 @@ function empty(rung, reason) { dropped: 0, pointers: 0, emptyReason: reason, - refIds: [] + refIds: [], + entries: [] }; } function failure(state, error, rung) { @@ -2394,6 +2453,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; @@ -2456,11 +2516,60 @@ function messageOf2(err) { } } +// lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync2, + readFileSync as readFileSync5, + readSync, + statSync as statSync4, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname3, join as join9 } from "node:path"; +var SCORE_LOG_VERSION = 1; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join9(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname3(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} + // lib/runid.mjs import { spawnSync as spawnSync2 } from "node:child_process"; import { createHash as createHash4 } from "node:crypto"; -import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync4 } from "node:fs"; -import { basename as basename2, dirname as dirname3, join as join9, resolve as resolve2 } from "node:path"; +import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync5 } from "node:fs"; +import { basename as basename2, dirname as dirname4, join as join10, resolve as resolve2 } from "node:path"; var STRATEGIES = /* @__PURE__ */ new Set(["per-directory", "git-branch", "per-conversation", "static"]); var DEFAULT_STRATEGY = "per-directory"; var SOURCES = /* @__PURE__ */ new Set(["startup", "resume", "clear", "compact", "fork"]); @@ -2571,7 +2680,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2671,7 +2780,7 @@ function normaliseRecord(record) { return out; } function sessionPath(file) { - return join9(dataDir({}), "sessions", `${file}.json`); + return join10(dataDir({}), "sessions", `${file}.json`); } function sessionFileName(sessionId) { const raw = typeof sessionId === "string" ? sessionId.trim() : ""; @@ -2692,7 +2801,7 @@ function usableDir(v) { const s = typeof v === "string" ? v.trim() : ""; if (!s) return ""; try { - return statSync4(s).isDirectory() ? s : ""; + return statSync5(s).isDirectory() ? s : ""; } catch { return ""; } @@ -2722,8 +2831,8 @@ function hasGitDir2(start) { try { let cur = resolve2(start); for (let i = 0; i < 24; i++) { - if (existsSync5(join9(cur, ".git"))) return true; - const up = dirname3(cur); + if (existsSync5(join10(cur, ".git"))) return true; + const up = dirname4(cur); if (up === cur) return false; cur = up; } @@ -2787,7 +2896,7 @@ function safeCwd2() { // hooks/src/subagent-start.mjs var OWN_AGENTS = /* @__PURE__ */ new Set(["mubit-recall", "mubit-memory:mubit-recall"]); var MAX_QUERY_CHARS = 2e3; -var MAX_ID = 128; +var MAX_ID2 = 128; var CFG = safeConfig2(); var RECALL_BUDGET_MS = clampInt(CFG.recallBudgetMs, 1500, 50, 1e4); var HARNESS_BUDGET_MS = Math.min(RECALL_BUDGET_MS + 400, 2800); @@ -2828,13 +2937,13 @@ await runHook("subagent-start", { } const outcome = await recallBlock(cfg, { runId, - // the PARENT run: nothing is stored under a sub-run id (§4.3). + // the PARENT run: nothing is stored under a sub-run id. agentId, // …but the subagent's own identity, so siblings are separable. query, deadline: deadline2, tokenBudget: CFG.subagentRecallTokenBudget, - // §5.2 — the same rule over the same query text the parent's own prompt gets. The + // The same rule over the same query text the parent's own prompt gets. The // staged parent turn is the only description of this subagent's task, so it is also // the only thing that can say the task is a handoff; a fan-out spawned off "where were // we?" wants the recency emphasis its parent turn got, or the parent is caught up and @@ -2858,6 +2967,16 @@ await runHook("subagent-start", { } persistSubRun(cfg, { runId, subRunId, agentId, payload, outcome, ms, pins }); if (!outcome.block) return pinsOnly(runId, agentId, pins); + if (cfg.capture !== false) { + try { + appendScoreRow(cfg, str5(payload?.session_id), { + kind: "refs", + source: "subagent", + refs: [...outcome.refIds] + }); + } catch { + } + } return { hookSpecificOutput: { hookEventName: "SubagentStart", @@ -2876,9 +2995,9 @@ await runHook("subagent-start", { }); function parentQuery(cfg, runId, payload) { try { - const promptId = safeSegment(turnKey(payload), MAX_ID); + const promptId = safeSegment(turnKey(payload), MAX_ID2); if (!promptId) return ""; - const file = join10(runDir(cfg, runId), "turns", `${promptId}.json`); + const file = join11(runDir(cfg, runId), "turns", `${promptId}.json`); const turn = readJson(file, null); return isObject7(turn) ? str5(turn.prompt).slice(0, MAX_QUERY_CHARS) : ""; } catch { @@ -2887,10 +3006,10 @@ function parentQuery(cfg, runId, payload) { } function persistSubRun(cfg, o) { try { - const name = safeSegment(o.subRunId, MAX_ID); + const name = safeSegment(o.subRunId, MAX_ID2); if (!name) return; - const dir = join10(runDir(cfg, o.runId), "subagents"); - writeJsonAtomic(join10(dir, `${name}.json`), { + const dir = join11(runDir(cfg, o.runId), "subagents"); + writeJsonAtomic(join11(dir, `${name}.json`), { sub_run_id: o.subRunId, parent_run_id: o.runId, // The host's own id, unmodified — `mubit_agent_id` is what went on the wire. Both, @@ -2909,7 +3028,7 @@ function persistSubRun(cfg, o) { sources: (o.outcome?.refIds?.length ?? 0) || numOr2(o.outcome?.sources, 0), tokens: numOr2(o.outcome?.tokens, 0), // Characters are what was actually injected; the token figure is a - // four-chars-per-token estimate (§4.10) a later reader can re-derive from these. + // four-chars-per-token estimate a later reader can re-derive from these. chars: str5(o.outcome?.block).length, dropped: numOr2(o.outcome?.dropped, 0), // Always 0 while the seen-set stays out of this path. Recorded rather than assumed, diff --git a/integrations/claude-code/mcp/dist/index.js b/integrations/claude-code/mcp/dist/index.js index 530d12b..49b3b2c 100644 --- a/integrations/claude-code/mcp/dist/index.js +++ b/integrations/claude-code/mcp/dist/index.js @@ -182,7 +182,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -302,6 +302,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -375,6 +385,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -863,7 +875,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -1045,8 +1057,8 @@ function safeCwd2() { } // mcp/src/egress.mjs -import { readdirSync as readdirSync3, statSync as statSync5 } from "node:fs"; -import { join as join8 } from "node:path"; +import { readdirSync as readdirSync4, statSync as statSync6 } from "node:fs"; +import { join as join9 } from "node:path"; // lib/breaker.mjs import { createHash as createHash3 } from "node:crypto"; @@ -1329,7 +1341,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1396,7 +1408,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1846,8 +1858,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -2042,6 +2054,73 @@ function clamp2(v, lo, hi, dflt) { return Math.min(hi, Math.max(lo, Math.trunc(n))); } +// lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function isHandle(v) { + return BARE_RE.test(bareOf(v)); +} +function resolveHandles(ids, knownRefs) { + const byHandle = /* @__PURE__ */ new Map(); + for (const ref of Array.isArray(knownRefs) ? knownRefs : []) { + const h = handleFor(ref); + if (h) byHandle.set(h, ref); + } + const out = []; + const unresolved = []; + for (const raw of Array.isArray(ids) ? ids : []) { + if (typeof raw !== "string") continue; + const bare = bareOf(raw); + if (BARE_RE.test(bare)) { + const ref = byHandle.get(bare); + if (ref) out.push(ref); + else { + out.push(bare); + unresolved.push(bare); + } + } else if (raw.trim()) { + out.push(raw.trim()); + } + } + return { ids: out, unresolved }; +} +function knownRefsFromRows(rows) { + const last = /* @__PURE__ */ new Map(); + let n = 0; + const note2 = (ref) => { + if (typeof ref === "string" && ref.trim()) last.set(ref.trim(), n++); + }; + for (const row of Array.isArray(rows) ? rows : []) { + if (!row || typeof row !== "object") continue; + if (row.kind !== "start" && row.kind !== "shown" && row.kind !== "refs") continue; + if (row.lessons && typeof row.lessons === "object") for (const ref of Object.keys(row.lessons)) note2(ref); + if (Array.isArray(row.refs)) for (const ref of row.refs) note2(ref); + } + return [...last.entries()].sort((a, b) => a[1] - b[1]).map(([ref]) => ref); +} +function bareOf(v) { + const s = typeof v === "string" ? v.trim() : ""; + return s.startsWith("[") && s.endsWith("]") ? s.slice(1, -1).trim() : s; +} + // lib/markers.mjs import { join as join7 } from "node:path"; function defaultMarker(runId = "") { @@ -2051,7 +2130,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -2124,11 +2203,97 @@ function updateMarker(cfg, runId, patch = {}) { } } +// lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync3, + readFileSync as readFileSync5, + readSync, + statSync as statSync5, + writeSync as writeSync2 +} from "node:fs"; +import { dirname as dirname4, join as join8 } from "node:path"; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join8(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync5(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync2(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync2(fd); + } + partialHead = true; + } else { + text = readFileSync5(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} +function recentScoreLogs(cfg, opts = {}) { + try { + const dir = join8(resolveDataDir(cfg), SCORE_DIR); + const now = Date.now(); + const maxAge = Number(opts?.maxAgeMs) > 0 ? Number(opts.maxAgeMs) : SCORE_LOG_TTL_MS; + const limit = Number(opts?.limit) > 0 ? Math.trunc(Number(opts.limit)) : 20; + const found = []; + for (const name of readdirSync3(dir)) { + if (!name.endsWith(".jsonl")) continue; + const p = join8(dir, name); + try { + const st = statSync5(p); + if (st.isFile() && now - st.mtimeMs <= maxAge) found.push({ p, m: st.mtimeMs }); + } catch { + } + } + found.sort((a, b) => b.m - a.m); + return found.slice(0, limit).map((f) => f.p); + } catch { + return []; + } +} + // mcp/src/egress.mjs var LATTICE = ["run", "session", "global", "org"]; var CEILINGS = ["run", "session", "global"]; var INGEST_PATH = "/v2/control/ingest"; var ARCHIVE_PATH = "/v2/control/archive"; +var OUTCOME_PATH = "/v2/control/outcome"; +var DEREFERENCE_PATH = "/v2/control/dereference"; +var HANDLES_NOTE_KEY = "mubit_handles"; +var HANDLES_HINT = "These ids match no memory line shown in this session and were sent as typed. Use the [m\u2026] id printed on a memory line, or a full reference_id."; var OPEN_TURN_GRACE_MS = 6e4; var TURN_FILES_TO_READ = 16; var LESSONS_PATH = "/v2/control/lessons"; @@ -2264,19 +2429,19 @@ function provenanceStamp(cfg, runId, sessionId, now = Date.now()) { return stamp; } function openTurn(cfg, runId, sessionId, now) { - const dir = join8(runDir(cfg, runId), "turns"); + const dir = join9(runDir(cfg, runId), "turns"); let names; try { - names = readdirSync3(dir).filter((f) => f.endsWith(".json")); + names = readdirSync4(dir).filter((f) => f.endsWith(".json")); } catch { return null; } if (names.length > TURN_FILES_TO_READ) { - names = names.map((f) => ({ f, at: mtimeOf(join8(dir, f)) })).sort((a, b) => b.at - a.at).slice(0, TURN_FILES_TO_READ).map((e) => e.f); + names = names.map((f) => ({ f, at: mtimeOf(join9(dir, f)) })).sort((a, b) => b.at - a.at).slice(0, TURN_FILES_TO_READ).map((e) => e.f); } let best = null; for (const f of names) { - const t = readJson(join8(dir, f), null); + const t = readJson(join9(dir, f), null); if (!isPlainObject3(t)) continue; if (String(t.session_id ?? "") !== sessionId) continue; if (!best || num2(t.started_at) > num2(best.started_at)) best = t; @@ -2290,7 +2455,7 @@ function openTurn(cfg, runId, sessionId, now) { } function mtimeOf(p) { try { - return statSync5(p).mtimeMs; + return statSync6(p).mtimeMs; } catch { return 0; } @@ -2428,6 +2593,72 @@ function recordMcpIngest(cfg, runId, items) { function countItems(body) { return Array.isArray(body?.items) ? body.items.length : 0; } +function resolveOutcomeBody(body, knownRefs) { + return resolveFields(body, knownRefs, true); +} +function resolveDereferenceBody(body, knownRefs) { + return resolveFields(body, knownRefs, false); +} +function knownRefsFor(cfg, sessionId, runId = "") { + try { + const c = cfg ?? {}; + const sid = sessionId || (runId ? latestSessionOf(c, runId) : ""); + const own = sid ? scorecardPath(c, sid) : ""; + const rows = []; + for (const p of recentScoreLogs(c).reverse()) if (p !== own) rows.push(...readRowsAt(p)); + if (own) rows.push(...readRowsAt(own)); + return knownRefsFromRows(rows); + } catch { + return []; + } +} +function latestSessionOf(cfg, runId) { + const dir = join9(runDir(cfg, runId), "turns"); + let names; + try { + names = readdirSync4(dir).filter((f) => f.endsWith(".json")); + } catch { + return ""; + } + const recent = names.map((f) => ({ f, at: mtimeOf(join9(dir, f)) })).sort((a, b) => b.at - a.at).slice(0, TURN_FILES_TO_READ); + let best = ""; + let bestAt = -1; + for (const { f } of recent) { + const t = readJson(join9(dir, f), null); + const sid = isPlainObject3(t) && typeof t.session_id === "string" ? t.session_id.trim() : ""; + if (sid && num2(t.started_at) > bestAt) { + best = sid; + bestAt = num2(t.started_at); + } + } + return best; +} +function carriesHandle(body, withEntries) { + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + if (isHandle(body.reference_id)) return true; + return withEntries && Array.isArray(body.entry_ids) && body.entry_ids.some((id) => isHandle(id)); +} +function resolveFields(body, knownRefs, withEntries) { + const noop = { body, changed: false, unresolved: ( + /** @type {string[]} */ + [] + ) }; + try { + if (!carriesHandle(body, withEntries)) return noop; + const unresolved = []; + const one = (id) => { + if (!isHandle(id)) return id; + const r = resolveHandles([id], knownRefs); + unresolved.push(...r.unresolved); + return r.ids[0] ?? id; + }; + const next = { ...body, reference_id: one(body.reference_id) }; + if (withEntries && Array.isArray(body.entry_ids)) next.entry_ids = body.entry_ids.map(one); + return { body: next, changed: true, unresolved }; + } catch { + return noop; + } +} function installFetchGuard(opts) { const ceiling = resolveCeiling(opts?.ceiling); const runId = typeof opts?.runId === "string" ? opts.runId : ""; @@ -2484,6 +2715,18 @@ function installFetchGuard(opts) { const out = stampProvenance(parsed.value, stampNow(), { at: "body" }); if (out.stamped) sendInit = { ...init, body: JSON.stringify(out.body) }; } + } else if (isPostTo(input, init, OUTCOME_PATH) || isPostTo(input, init, DEREFERENCE_PATH)) { + const parsed = parseBody(init); + const outcome = isPostTo(input, init, OUTCOME_PATH); + if (parsed.ok && carriesHandle(parsed.value, outcome)) { + const refs = knownRefsFor(opts?.cfg, sessionId, runId); + const out = outcome ? resolveOutcomeBody(parsed.value, refs) : resolveDereferenceBody(parsed.value, refs); + if (out.changed) sendInit = { ...init, body: JSON.stringify(out.body) }; + if (out.unresolved.length) { + note2 = { unresolved: out.unresolved, hint: HANDLES_HINT }; + noteKey = HANDLES_NOTE_KEY; + } + } } else if (isLessonsRead(input, init)) { const plan = await planLessons(init); if (plan.answer) return jsonResponse(plan.answer); @@ -2580,8 +2823,16 @@ var INSTRUCTIONS = [ "", "Which tool. mubit_recall for a topic or question in words. mubit_diagnose when a command or test has just failed, which matches the error shape against past failures. mubit_dereference when you already hold a reference_id. Reviewing the whole catalogue, the pattern across many lessons, a named checkpoint, deleting a lesson and an explicit reflect are skills (/mubit-memory:strategies, :checkpoint, :forget, :reflect), not tools.", "", - 'What to write back. mubit_learned records one durable claim \u2014 a constraint, a fix that worked, a standing preference \u2014 stated so it is still true in a later session. It is not a session log: narrating what happened ("the user asked for X", "I refactored Y") is the common way this tool is misused, and every future recall pays for it. mubit_outcome credits the reference_ids that actually helped, which is what makes the memory that helps rank higher next time.' + 'What to write back. mubit_learned records one durable claim \u2014 a constraint, a fix that worked, a standing preference \u2014 stated so it is still true in a later session. It is not a session log: narrating what happened ("the user asked for X", "I refactored Y") is the common way this tool is misused, and every future recall pays for it. Each injected memory line starts with an id in brackets, like [m7k2q]: pass those ids (or reference_ids) to mubit_outcome \u2014 outcome success for entries that helped, failure for ones that were wrong or misled you \u2014 which is what makes the memory that helps rank higher next time.' ].join("\n"); +var ALWAYS_LOAD_META = "anthropic/alwaysLoad"; +var ALWAYS_LOAD_TOOLS = Object.freeze(["mubit_outcome", "mubit_learned"]); +function alwaysLoadFor(cfg) { + const c = cfg && typeof cfg === "object" ? cfg : {}; + if (String(c.outcomeReview ?? "").trim().toLowerCase() === "off") return []; + if (c.host === "codex") return []; + return [...ALWAYS_LOAD_TOOLS]; +} function guardInitialize(message, instructions) { const noop = { message, changed: false }; try { @@ -2602,8 +2853,31 @@ function guardInitialize(message, instructions) { return noop; } } +function guardToolsList(message, names) { + const noop = { message, changed: false }; + try { + const wanted = new Set(Array.isArray(names) ? names : []); + if (!wanted.size) return noop; + if (!message || typeof message !== "object" || Array.isArray(message)) return noop; + if (message.jsonrpc !== "2.0") return noop; + const result = message.result; + if (!result || typeof result !== "object" || !Array.isArray(result.tools)) return noop; + let changed = false; + const tools = result.tools.map((t) => { + if (!t || typeof t !== "object" || !wanted.has(t.name)) return t; + const meta = t._meta && typeof t._meta === "object" && !Array.isArray(t._meta) ? t._meta : {}; + if (meta[ALWAYS_LOAD_META] === true) return t; + changed = true; + return { ...t, _meta: { ...meta, [ALWAYS_LOAD_META]: true } }; + }); + return changed ? { message: { ...message, result: { ...result, tools } }, changed } : noop; + } catch { + return noop; + } +} function installInstructionsGuard(opts) { const instructions = typeof opts?.instructions === "string" ? opts.instructions : ""; + const alwaysLoad = Array.isArray(opts?.alwaysLoad) ? opts.alwaysLoad.filter((n) => typeof n === "string" && n.trim()) : []; if (instructions.trim() === "") return; const stream = opts?.stream ?? process.stdout; const current = stream?.write; @@ -2621,6 +2895,13 @@ function installInstructionsGuard(opts) { } catch { } } + if (alwaysLoad.length && typeof chunk === "string" && chunk.includes('"tools":[')) { + try { + const marked = rewriteLines(chunk, (frame) => guardToolsList(frame, alwaysLoad)); + if (marked !== null) chunk = marked; + } catch { + } + } return base.call(this, chunk, ...rest); }; Object.defineProperty(wrapped, "mubitInstructionsGuardOriginal", { @@ -2629,11 +2910,14 @@ function installInstructionsGuard(opts) { configurable: true, enumerable: false }); - wrapped.mubitInstructionsGuard = { chars: instructions.length }; + wrapped.mubitInstructionsGuard = { chars: instructions.length, alwaysLoad }; stream.write = wrapped; } function fill(chunk, instructions) { if (!chunk.includes('"result"')) return null; + return rewriteLines(chunk, (frame) => guardInitialize(frame, instructions)); +} +function rewriteLines(chunk, guard) { const parts = chunk.split("\n"); let changed = false; for (let i = 0; i < parts.length; i += 1) { @@ -2644,7 +2928,7 @@ function fill(chunk, instructions) { } catch { continue; } - const out = guardInitialize(frame, instructions); + const out = guard(frame); if (!out.changed) continue; parts[i] = JSON.stringify(out.message); changed = true; @@ -2654,7 +2938,7 @@ function fill(chunk, instructions) { // mcp/src/results.mjs import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs"; -import { join as join10 } from "node:path"; +import { join as join11 } from "node:path"; // lib/assemble.mjs var SECTION_KEYS = Object.freeze([ @@ -2740,7 +3024,7 @@ function firstClause(text) { } // lib/seen.mjs -import { join as join9 } from "node:path"; +import { join as join10 } from "node:path"; var SEEN_TTL_MS = 6 * 60 * 60 * 1e3; var MAX_SEEN_REFS = 512; var SEEN_DIR = "seen"; @@ -2752,7 +3036,7 @@ function seenPath(cfg, runId, sessionId) { if (!safeSegment(runId)) return ""; const session = safeSegment(hostSessionId({ session_id: sessionId }), MAX_SESSION_SEGMENT); if (!session) return ""; - return join9(runDir(cfg, runId), SEEN_DIR, `${session}.json`); + return join10(runDir(cfg, runId), SEEN_DIR, `${session}.json`); } function readSeen(cfg, runId, sessionId = "") { try { @@ -3071,10 +3355,10 @@ function spillWriter(cfg, runId) { return (text, shape) => { try { if (!safeSegment(runId)) return ""; - const dir = join10(runDir(cfg, runId), SPILL_DIR); + const dir = join11(runDir(cfg, runId), SPILL_DIR); mkdirSync3(dir, { recursive: true }); const ext = shape === "text" || shape === "error" ? "txt" : "json"; - const p = join10(dir, `${Date.now()}-${safeSegment(shape) || "result"}-${n++}.${ext}`); + const p = join11(dir, `${Date.now()}-${safeSegment(shape) || "result"}-${n++}.${ext}`); writeFileSync2(p, text, { encoding: "utf8", mode: 384 }); return p; } catch { @@ -3171,7 +3455,7 @@ function prepare(env) { const sessionId = hostPayload(env).session_id ?? ""; const ceiling = resolveCeiling(cfg.mcpLessonScope); installFetchGuard({ ceiling, runId, pinRun: true, cfg, sessionId }); - installInstructionsGuard({ instructions: INSTRUCTIONS }); + installInstructionsGuard({ instructions: INSTRUCTIONS, alwaysLoad: alwaysLoadFor(cfg) }); installResultsGuard({ cfg, runId, diff --git a/integrations/claude-code/skills/activity/SKILL.md b/integrations/claude-code/skills/activity/SKILL.md index 76de53c..8a41bf0 100644 --- a/integrations/claude-code/skills/activity/SKILL.md +++ b/integrations/claude-code/skills/activity/SKILL.md @@ -42,7 +42,7 @@ The default is one page of the newest activity for the newest run in this data d `--all-runs` asks across every run the key can see. Filters: `--type` (repeatable), `--since` / `--until` as RFC3339, `--agent`, `--user`, and -`--exclude-derived` for the entries the instance promoted for itself rather than ones a client +`--exclude-derived` for the entries the instance derived itself rather than ones a client wrote. `--full` returns every field untruncated; the default is five keys per entry. Output: a table by default, `--jsonl` for one object per line, `--json` for one envelope. The @@ -66,18 +66,16 @@ reply** — the command prints it, and a file the user cannot find is a file the `--out` refuses to overwrite, and refuses any path inside the plugin data directory: an export there sits outside the TTL sweep, so it would live forever and nothing would ever mention it again. It warns when the destination is inside a git working tree that is not ignoring it. -`--all-runs` is refused with `--export`, because that route takes no limit and a run is the -only bound on how much comes back. +`--all-runs` cannot be combined with `--export`: an export covers one run. ## What to say about the numbers Three of the things this prints are **findings**, not decoration, and they are the reason the answer can be trusted. Relay them rather than summarising them away: -1. **"the instance did not honour exclude_derived"** means derived entries came back under a - request that excluded them, and this client dropped them locally. The listing is correct; - the server's filter is not. That is worth telling the user, because it is the difference - between a filter and a claim about a filter. +1. **"the instance did not honour exclude_derived"** means derived entries came back and this + client dropped them locally, so the listing is still correct. Say so, so the user knows the + filter was applied here. 2. **"the instance did not honour the compact projection"** means content was truncated here rather than upstream. Nothing was lost — `--full` or an export has all of it. 3. **"this answer is incomplete"** on a `--scan` means it stopped at a bound (entry cap, wall diff --git a/integrations/claude-code/skills/doctor/SKILL.md b/integrations/claude-code/skills/doctor/SKILL.md index 3545b94..49e4c10 100644 --- a/integrations/claude-code/skills/doctor/SKILL.md +++ b/integrations/claude-code/skills/doctor/SKILL.md @@ -22,7 +22,7 @@ one before it, and the cheap steps answer most questions. below will come back clean while the model receives no memory at all. Read `recall.empty_reason` for which kind: - `policy_denied` — the instance has direct-access recall (rung 1) disabled, and the - `agent_routed` fallback is off by default because it costs an LLM call per prompt. Ask + `agent_routed` fallback is off by default because it adds seconds to every prompt. Ask the operator to enable direct search. `MUBIT_CC_RECALL_FALLBACK=agent_routed` restores recall at that cost; `MUBIT_CC_POLICY_TTL_MS=1` re-probes immediately once it is on. - `budget_exhausted` — recall ran out of time before the call returned. Raise @@ -46,12 +46,9 @@ one before it, and the cheap steps answer most questions. | `skipped:not-ingested` | " | Nothing was ingested this session, so there was no tail to reflect over. | | `skipped:undrained` | " | The spool did not land, so reflecting would have drawn conclusions from a session the server only half has. The next session drains the rest and reflects then. | - **A `failed` reflect whose `last_error` is an HTTP 504 is the known one.** Reflection over - a real run runs long enough that it sits on a cliff, and ordinary latency variance decides - it — the identical request, issued four times in a row, has returned 504, 504, 200, 504. - That is why this call retries: `attempts: 2` with a 504 means both throws lost, which - happens to a minority of sessions and is **not** an instance fault. Do not send the user to check their - key, their endpoint or their network for it; the same instance is answering every other + **A `failed` reflect whose `last_error` is an HTTP 504 timed out, and the call retries + once.** `attempts: 2` with a 504 means both tries timed out. Do not send the user to check + their key, their endpoint or their network for it; the same instance is answering every other route. What it costs is real, though — that session's lessons stay at `run` scope and are invisible to the next session. If it is failing on most sessions rather than some, that is worth escalating, and the number to quote is how many consecutive session markers read @@ -71,10 +68,10 @@ one before it, and the cheap steps answer most questions. (`MUBIT_MCP_LESSON_SCOPE`), default `session`. It is a `userConfig` key, so it resolves through the ordinary precedence — environment, then `/plugin` → Mubit Memory → configure. At `run`, every lesson `mubit_learned` writes stays inside the run that wrote - it, and nothing later widens it: reflection stamps `run` too. + it. - **What is actually stored, by scope** — `node scripts/scope-audit.mjs`. It reports the - scope distribution across every run, how many lessons are visible outside the run that - wrote them, and whatever promotion metadata the instance stamped. Run it before and + scope distribution across every run and how many lessons are visible outside the run that + wrote them. Run it before and after changing the setting; a single reading says nothing. A ceiling of `run` with a user who expected cross-session lessons is the whole fault, and diff --git a/integrations/claude-code/skills/forget/SKILL.md b/integrations/claude-code/skills/forget/SKILL.md index 76a8061..8c984a5 100644 --- a/integrations/claude-code/skills/forget/SKILL.md +++ b/integrations/claude-code/skills/forget/SKILL.md @@ -44,10 +44,9 @@ mubit_outcome reference_id= outcome=failure signal=-1 rationale="" ``` -The promotion pipeline acts on that. The lesson's confidence drops, it stops surfacing near -the top of recall, and — this is the part deletion cannot do — the *reason* it was wrong is -now part of the record. A deleted lesson teaches the system nothing. A down-weighted one -teaches it something, and the correction survives to shape what gets promoted next. +That makes the lesson less likely to be recalled, and — this is the part deletion cannot do — +the *reason* it was wrong is now part of the record. A deleted lesson teaches the system +nothing. A down-weighted one teaches it something, and the correction survives. ## When deletion is actually right diff --git a/integrations/claude-code/skills/handoff/SKILL.md b/integrations/claude-code/skills/handoff/SKILL.md index 6bee6a5..51204fd 100644 --- a/integrations/claude-code/skills/handoff/SKILL.md +++ b/integrations/claude-code/skills/handoff/SKILL.md @@ -43,8 +43,7 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/handoff.mjs" list --open --data-dir "${MUBIT_CC_ Without `--open`, every handoff in the run, each with the feedback that answered it. "Open" is computed by this command, not by the instance: a handoff is open when no feedback entry -names its id. The instance never flips a handoff's `active` flag, so do not read that field as -its state. +names its id. Do not read a handoff's `active` field as its state. ## Answer diff --git a/integrations/claude-code/skills/recall/SKILL.md b/integrations/claude-code/skills/recall/SKILL.md index b9f91eb..265dde7 100644 --- a/integrations/claude-code/skills/recall/SKILL.md +++ b/integrations/claude-code/skills/recall/SKILL.md @@ -15,9 +15,8 @@ When you do: 2. Read the evidence. If it answers the question, stop. 3. Only if the first call returned nothing usable, issue one reformulated call. -Never fan out into parallel searches across sub-topics. Mubit retrieval is hybrid -(semantic + lexical + recency + graph); one well-formed query beats four keyword slices at -a quarter of the latency. Two calls is the ceiling. +Never fan out into parallel searches across sub-topics: one well-formed query beats four +keyword slices at a quarter of the latency. Two calls is the ceiling. Cite what you use by its `reference_id`, and call `mubit_outcome` with those `entry_ids` when recalled memory turns out to be right or wrong. That feedback is what makes the next @@ -26,8 +25,8 @@ recall better. ## Writing the query Query with the *question*, not with keywords. "Why does the drain hook retry twice on a -5xx" retrieves better than "drain retry 5xx", because the semantic half of the hybrid index -has something to match on and the lexical half still catches the identifiers. Include the +5xx" retrieves better than "drain retry 5xx": a full question matches better, and the +identifiers inside it still count. Include the identifiers you already know — file names, symbol names, error strings — inside the sentence rather than instead of it. diff --git a/integrations/claude-code/skills/reflect/SKILL.md b/integrations/claude-code/skills/reflect/SKILL.md index 635d7e1..7020359 100644 --- a/integrations/claude-code/skills/reflect/SKILL.md +++ b/integrations/claude-code/skills/reflect/SKILL.md @@ -54,17 +54,15 @@ is the whole catalogue with its metadata. ## Why the explicit call exists at all -Mubit already extracts lessons on its own, in the background, as it ingests. It does that -perfectly well — and then stops there. Lessons extracted that way keep the scope they were -extracted at, typically `run`, and **a `run`-scoped lesson is invisible to the next session**. +A lesson drawn from a run's activity starts at `run` scope, and **a `run`-scoped lesson is +invisible to the next session**. (A `mubit_learned` write is different: it takes the +`mcpLessonScope` setting, `session` by default.) Lessons drawn from activity reach later +sessions only after an explicit reflect — this skill, or the one `SessionEnd` issues. Without +one, the store can look busy, lessons accumulating steadily, while nothing reaches a future +session. -The consequence is concrete: the store can look busy, lessons accumulating steadily, while -nothing ever crosses the boundary into a future session. Widening a lesson's scope is -reserved for the explicit reflect path — this skill, and the one `SessionEnd` issues. - -Widening is still gradual once a lesson is on that path. Rules are never scope-promoted, since -they are enforced as written; anything else has to establish itself before it travels. Expect -lessons to widen over several sessions, not on the first reflect. +Even then it is gradual: expect lessons to reach later sessions over several sessions, not on +the first reflect. ## When to invoke it @@ -72,11 +70,11 @@ lessons to widen over several sessions, not on the first reflect. That covers routine hygiene. Invoke this skill for a **mid-session checkpoint** — a long session that has just finished a real chunk of work, a debugging arc that ended in something worth keeping, or the point where the user is about to compact and wants the lessons banked -first. Do not call it every few turns: it is an LLM-backed extraction pass over the run, and -calling it on a run that has barely changed costs time and returns the same lessons. +first. Do not call it every few turns: it is slow, and calling it on a run that has barely +changed costs time and returns the same lessons. One timing detail worth knowing before you read a zero as a failure: reflection only sees -items the server has already **indexed**. A reflect fired immediately after a burst of +items that are already **searchable**. A reflect fired immediately after a burst of captures or an explicit `/mubit-memory:remember` can honestly report `lessons_stored: 0` where the same run reflected about a minute later returns them. If you have just written something you expect to be reflected on, give ingest a moment rather than reflecting twice. diff --git a/integrations/claude-code/skills/remember/SKILL.md b/integrations/claude-code/skills/remember/SKILL.md index 3726b17..9b35010 100644 --- a/integrations/claude-code/skills/remember/SKILL.md +++ b/integrations/claude-code/skills/remember/SKILL.md @@ -32,10 +32,10 @@ and keep it self-contained: it will be read months later with none of this conve around it. `mubit_learned` returns when the write is **queued**, not stored. Do not immediately search -for what you just saved; it will not be there yet. Ingest runs asynchronously — the item is -embedded and indexed after the call returns, so a search fired in the same turn honestly +for what you just saved; it will not be there yet. Ingest runs asynchronously — the item +becomes searchable a little after the call returns, so a search fired in the same turn honestly returns nothing and that is not a sign that memory is broken. The same applies to -`/mubit-memory:reflect`: reflection only sees items the server has already indexed, so +`/mubit-memory:reflect`: reflection only sees items that are already searchable, so reflecting seconds after a write reports zero lessons where reflecting a minute later reports them. diff --git a/integrations/codex/.codex-plugin/plugin.json b/integrations/codex/.codex-plugin/plugin.json index a015dbb..9d8ef94 100644 --- a/integrations/codex/.codex-plugin/plugin.json +++ b/integrations/codex/.codex-plugin/plugin.json @@ -20,7 +20,7 @@ "interface": { "displayName": "Mubit Memory", "shortDescription": "Persistent memory for Codex", - "longDescription": "Mubit Memory gives Codex a memory that survives the session. Every tool call is captured, relevant lessons are recalled before each prompt, outcomes are attributed back so what helps ranks higher next time, and a reflection at session end promotes what was learned beyond the run it was learned in.", + "longDescription": "Mubit Memory gives Codex a memory that survives the session. Every tool call is captured, relevant lessons are recalled before each prompt, outcomes are attributed back so what helps ranks higher next time, and a reflection at session end carries what was learned into later sessions.", "developerName": "Mubit AI", "category": "Productivity", "capabilities": [ diff --git a/integrations/codex/README.md b/integrations/codex/README.md index 6e61831..7e496e8 100644 --- a/integrations/codex/README.md +++ b/integrations/codex/README.md @@ -2,8 +2,8 @@ Persistent, typed, self-improving memory for the OpenAI Codex CLI. Work is captured involuntarily as it happens, relevant lessons are injected before every prompt, outcomes are -attributed back so what helps ranks higher next time, and a reflection at session end promotes -what was learned beyond the run it was learned in. +attributed back so what helps ranks higher next time, and a reflection at session end carries +what was learned into later sessions. It is the same plugin as [`../claude-code`](../claude-code): one `lib/`, one set of hook bodies, one MCP launcher, built twice. **A Codex session and a Claude Code session started in @@ -83,9 +83,8 @@ unless you have turned the warnings on. ## Configuration -Codex has no plugin settings UI and exports no `CODEX_PLUGIN_OPTION_*` variables — the strings -`PLUGIN_OPTION` and `userConfig` appear nowhere in its binary — so configuration is three -rungs, highest first: +Codex has no plugin settings UI and exports no `CODEX_PLUGIN_OPTION_*` variables, so +configuration is three rungs, highest first: 1. `MUBIT_*` environment variables 2. `/credentials.json`, written by `mubit-memory:auth` @@ -109,6 +108,8 @@ The settings worth knowing, all `MUBIT_CC_*` unless noted: | `MUBIT_CC_SESSION_END_DETACH` | `1` | Finish the end-of-session flush in a detached process. **Leave this on under Codex** — see below. | | `MUBIT_CC_PRE_TOOL_WARNINGS` | `0` | Show a stored rule before a matching tool call. It only ever warns. | | `MUBIT_CC_PINS` | `1` | Render the constraints pinned with the `pin` skill above the recalled block on every prompt of the run — including the prompts recall skips — and above the block a subagent is given at `SubagentStart`. Capped at five pins, 200 characters each and 240 tokens (96 for a subagent); costs no extra request on the prompt path. Off restores the injected block exactly. | +| `MUBIT_CC_SESSION_SCORE` | `off` here | The memory scorecard the Stop hook can print under each reply that showed a lesson (`full`, `compact` or `off`). Off by default under Codex, where the card has not been verified; see [the session scorecard](../claude-code/docs/user-guide.md#the-session-scorecard). | +| `MUBIT_CC_OUTCOME_REVIEW` | `nudge` here | How hard the model is asked to credit memory by the short id (`[m7k2q]`) now printed on every injected line. `nudge` is one sentence in the memory block; `stop` also asks for a review from the Stop hook, which has not been verified under Codex; `off` does neither. See [crediting memory by id](../claude-code/docs/user-guide.md#crediting-memory-by-id-the-outcome-review). | | `MUBIT_CC_DATA_DIR` | — | Overrides where state lives. Highest precedence of any data-dir input. | | `MUBIT_CC_STATUSLINE` | `0` here | Defaults **off** under Codex, whose status line is a fixed list of built-in item ids with nothing scriptable in it. | | `MUBIT_MCP_TOOLS` (no `_CC`) | — | Which MCP tools to register, comma-separated. Blank means the seven below. A list you supply is used **verbatim**, not unioned with that default, so it is also how you reach the other eight. | @@ -116,8 +117,8 @@ The settings worth knowing, all `MUBIT_CC_*` unless noted: ### The three-second SessionEnd Codex clamps a `SessionEnd` hook to three seconds and kills it there, whatever the -registration asks for. The end-of-session flush — the drain, and the reflect that is the only -thing promoting a lesson beyond its own run — does not reliably fit. So the hook hands that +registration asks for. The end-of-session flush — the drain, and the reflect that carries +lessons into later sessions — does not reliably fit. So the hook hands that work to a detached process and returns immediately, which is why `MUBIT_CC_SESSION_END_DETACH` defaults on and why turning it off costs you reflections. @@ -152,8 +153,7 @@ A Codex-only user ends up with a `~/.claude/` directory they never asked for. `MUBIT_CC_DATA_DIR` moves it, at the cost of the sharing. Which harness wrote an entry is recorded as its agent role — `codex` or `claude-code` — so the -two are distinguishable where it matters, and count as two actors where something upstream is -asking how well attested a lesson is. +two are distinguishable where it matters. ### Sharing one run, and when it stops sharing @@ -392,9 +392,8 @@ to this session's role, addressed for review, with zero network — it rides the redaction and circuit breaker included — and under the **parent's** run id. A sub-run id never reaches the wire, and there is no way to address a note to another run. -"Open" is computed by the command, not by the instance: the instance never flips a handoff's -`active` flag and has no list route, so the command reads both entry types for the run and -joins them — open means no feedback names that id. With two sessions live in one data directory +"Open" is computed by the command: it reads both entry types for the run and joins them — open +means no feedback names that id. With two sessions live in one data directory the command refuses with `ambiguous_run` and names them rather than guessing; pass `--run`. --- diff --git a/integrations/codex/THIRD_PARTY_NOTICES.md b/integrations/codex/THIRD_PARTY_NOTICES.md index 9dc50e7..e792b1b 100644 --- a/integrations/codex/THIRD_PARTY_NOTICES.md +++ b/integrations/codex/THIRD_PARTY_NOTICES.md @@ -20,8 +20,8 @@ Most of the bundle is our own code (`@mubit-ai/mcp`, `@mubit-ai/sdk`), covered b | `long` | Apache-2.0 | Copyright 2016 Daniel Wirtz | | | `lodash.camelcase` | MIT | Copyright John-David Dalton (Lodash) | | -Versions are resolved at build time by the source repository's `mcp/package-lock.json` -(this published tree does not carry the lockfile); `@grpc/grpc-js` also embeds its version +Versions are resolved when the vendored server is built (this tree does not carry its +lockfile); `@grpc/grpc-js` also embeds its version string in the bundle, so `grep 'grpc-node-js/' mcp/dist/server.js` reads back what shipped. 1. The TypeScript SDK's upstream `LICENSE` is transitional: code contributed before the diff --git a/integrations/codex/bin/activity.mjs b/integrations/codex/bin/activity.mjs index e64cef2..11085c3 100644 --- a/integrations/codex/bin/activity.mjs +++ b/integrations/codex/bin/activity.mjs @@ -551,6 +551,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -624,6 +634,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -769,7 +781,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -1039,7 +1051,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1106,7 +1118,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1435,7 +1447,7 @@ async function exportActivity(cfg, params2 = {}) { return fail( 400, "bad_request", - "export requires a run id: the route takes no limit, and an empty run_id means every run this key can see \u2014 which is an unbounded response read into one string" + "export requires a run id: the route takes no limit, so an unscoped export is an unbounded response read into one string" ); } const req = { @@ -1517,8 +1529,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } diff --git a/integrations/codex/bin/admin.mjs b/integrations/codex/bin/admin.mjs index b08d433..23f0971 100755 --- a/integrations/codex/bin/admin.mjs +++ b/integrations/codex/bin/admin.mjs @@ -556,6 +556,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -629,6 +639,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -774,7 +786,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -1044,7 +1056,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1117,7 +1129,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1208,7 +1220,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1611,8 +1623,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -1833,6 +1845,18 @@ var init_activity = __esm({ } }); +// ../claude-code/lib/handles.mjs +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + // ../claude-code/lib/assemble.mjs function estimateTokens(text) { if (typeof text !== "string" || text.length === 0) return 0; @@ -1850,6 +1874,7 @@ function firstClause(text) { var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS, CHARS_PER_TOKEN, POINTER_MARK, MAX_POINTER_CHARS, MIN_POINTER_CHARS; var init_assemble = __esm({ "../claude-code/lib/assemble.mjs"() { + init_handles(); SECTION_KEYS = Object.freeze([ "mental_models", "active_rules", @@ -2030,6 +2055,16 @@ var init_markers = __esm({ } }); +// ../claude-code/lib/scorecard-log.mjs +var SCORE_LOG_TTL_MS, MAX_READ_BYTES; +var init_scorecard_log = __esm({ + "../claude-code/lib/scorecard-log.mjs"() { + init_state(); + SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + MAX_READ_BYTES = 4 * 1024 * 1024; + } +}); + // ../claude-code/mcp/src/egress.mjs function selectLessons(rows, o) { const mine = (r) => o.runId !== "" && (r.runId === o.runId || r.sourceRunId === o.runId); @@ -2055,8 +2090,10 @@ var SHOWING; var init_egress = __esm({ "../claude-code/mcp/src/egress.mjs"() { init_activity(); + init_handles(); init_markers(); init_runid(); + init_scorecard_log(); init_state(); SHOWING = { "": "this run, plus every lesson stored at a scope that reaches past the run that wrote it", diff --git a/integrations/codex/bin/dashboard.mjs b/integrations/codex/bin/dashboard.mjs index b401295..d1d7c61 100644 --- a/integrations/codex/bin/dashboard.mjs +++ b/integrations/codex/bin/dashboard.mjs @@ -569,6 +569,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -642,6 +652,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -787,7 +799,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -1057,7 +1069,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1127,7 +1139,7 @@ async function postOutcome(cfg, req, opts = {}) { req, "reference_id", "postOutcome", - 'pass "global" for run-level attribution and put the real ids in entry_ids[] (\xA71.3)' + 'pass "global" for run-level attribution and put the real ids in entry_ids[]' ) ); if (bad) return refuse(cfg, started, bad, { route: ROUTES.outcome }); @@ -1150,7 +1162,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1241,13 +1253,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -1877,8 +1889,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -2103,15 +2115,50 @@ var init_activity = __esm({ function decideOutcome(turn) { if (!isObject(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str4(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str4(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str4(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str4(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -2121,21 +2168,44 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str4(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + } + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str4(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject(v)) out[ref] = /** @type {any} */ + v; + } + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -2236,7 +2306,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -3754,8 +3824,8 @@ async function getRoute(ctx, res, path, url) { } if (path === "/api/lessons") { const payload = await lessonsPayload(cfg, { - // An empty `run` means every run, and that is the only spelling it gets. A second - // `allRuns` parameter would just be a second way to pin this tab back to one run, which + // An empty `run` is how this tab asks for lessons from all runs, and that is the only + // spelling it gets. A second `allRuns` parameter would just be a second way to pin this tab back to one run, which // is the bug that made a global lesson from another run structurally invisible. run: String(url.searchParams.get("run") ?? ""), // A rendering context, never a filter: it is what `fromOtherRun` is measured against. diff --git a/integrations/codex/bin/handoff.mjs b/integrations/codex/bin/handoff.mjs index cd10f2d..e16e3a2 100644 --- a/integrations/codex/bin/handoff.mjs +++ b/integrations/codex/bin/handoff.mjs @@ -306,6 +306,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -379,6 +389,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -524,7 +536,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -1044,7 +1056,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1150,7 +1162,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1241,7 +1253,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1515,8 +1527,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } diff --git a/integrations/codex/bin/import.mjs b/integrations/codex/bin/import.mjs index ef85e4e..4d00018 100644 --- a/integrations/codex/bin/import.mjs +++ b/integrations/codex/bin/import.mjs @@ -178,6 +178,7 @@ function posInt(v, d) { var CHUNK_BYTES, MAX_LINE_BYTES, TEXT_BLOCKS, MAX_CONTENT_DEPTH, NEWLINE; var init_transcript = __esm({ "../claude-code/lib/transcript.mjs"() { + init_codex_rollout(); CHUNK_BYTES = 256 * 1024; MAX_LINE_BYTES = 2 * 1024 * 1024; TEXT_BLOCKS = /* @__PURE__ */ new Set(["text", "input_text", "output_text"]); @@ -187,21 +188,38 @@ var init_transcript = __esm({ }); // ../claude-code/lib/codex-rollout.mjs -function isInjectedUserText(text) { - return typeof text === "string" && INJECTED_USER_RE.test(text); -} -function stripInjectedBlocks(content) { - if (typeof content === "string") return isInjectedUserText(content) ? "" : content; +function isHookFeedback(text) { + const end = text.trimEnd().length; + let at = 0; + do { + HOOK_PROMPT_OPEN_RE.lastIndex = at; + if (!HOOK_PROMPT_OPEN_RE.test(text)) return false; + const close = text.indexOf(HOOK_PROMPT_CLOSE, HOOK_PROMPT_OPEN_RE.lastIndex); + if (close === -1) return false; + at = close + HOOK_PROMPT_CLOSE.length; + } while (at < end); + return true; +} +function isInjectedUserText(text, opts) { + if (typeof text !== "string") return false; + if (INJECTED_USER_RE.test(text) || isHookFeedback(text)) return true; + return !opts?.keepUserActions && USER_ACTION_RE.test(text); +} +function stripInjectedBlocks(content, opts) { + if (typeof content === "string") return isInjectedUserText(content, opts) ? "" : content; if (!Array.isArray(content)) return content; - return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text))); + return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text, opts))); } -var TAIL_BYTES, HEAD_BYTES, INJECTED_USER_RE, NOT_FOUND; +var TAIL_BYTES, HEAD_BYTES, INJECTED_USER_RE, USER_ACTION_RE, HOOK_PROMPT_OPEN_RE, HOOK_PROMPT_CLOSE, NOT_FOUND; var init_codex_rollout = __esm({ "../claude-code/lib/codex-rollout.mjs"() { init_transcript(); TAIL_BYTES = 512 * 1024; HEAD_BYTES = 256 * 1024; - INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|turn_aborted|user_shell_command|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; + INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; + USER_ACTION_RE = /^\s*<(?:turn_aborted|user_shell_command)\b/; + HOOK_PROMPT_OPEN_RE = /\s*]*)?>/y; + HOOK_PROMPT_CLOSE = ""; NOT_FOUND = Object.freeze({ found: false, failed: false, @@ -263,7 +281,7 @@ var init_classify = __esm({ Write: ["trace", "medium"], MultiEdit: ["trace", "medium"], NotebookEdit: ["trace", "medium"], - // Shell — also subject to §4.4 self-reference suppression, which runs upstream of this + // Shell — also subject to self-reference suppression, which runs upstream of this // module in `capture.mjs` step 2. Bash: ["tool_output", "low"], BashOutput: ["tool_output", "low"], @@ -670,6 +688,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -743,6 +771,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -888,7 +918,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MAX_ENV_TAGS = 8; MODE = "hosted"; LANG_FILES = [ @@ -1781,7 +1811,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1857,7 +1887,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1948,7 +1978,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireItems(req) { const items = req && typeof req === "object" ? req.items : void 0; @@ -2204,7 +2234,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; diff --git a/integrations/codex/bin/pin.mjs b/integrations/codex/bin/pin.mjs index 62951e1..8a2fe44 100644 --- a/integrations/codex/bin/pin.mjs +++ b/integrations/codex/bin/pin.mjs @@ -316,6 +316,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -389,6 +399,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -534,15 +546,28 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); +// ../claude-code/lib/handles.mjs +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + // ../claude-code/lib/assemble.mjs var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS; var init_assemble = __esm({ "../claude-code/lib/assemble.mjs"() { + init_handles(); SECTION_KEYS = Object.freeze([ "mental_models", "active_rules", @@ -1125,7 +1150,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1192,7 +1217,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; diff --git a/integrations/codex/docs/user-guide.md b/integrations/codex/docs/user-guide.md index 7d7a57a..7b66935 100644 --- a/integrations/codex/docs/user-guide.md +++ b/integrations/codex/docs/user-guide.md @@ -196,7 +196,7 @@ Start a new session in a real project and just work. You do not invoke anything. | When | What the plugin does | | --- | --- | | Session starts | Derives a run id from your directory, registers the agent, pulls up to 5 standing (`global`) lessons, and injects a short block telling the model memory is active and which tool to reach for | -| Every prompt you send | Queries memory and injects what is relevant, within a 1500 ms budget and a 1500-token cap. Zero LLM calls — assembly is local | +| Every prompt you send | Queries memory and injects what is relevant, within a 1500 ms budget and a 1500-token cap. Assembled locally | | Every tool call | Redacts and spools it. Zero network on the hot path. `apply_patch` is recorded with the files it touched | | A permission request | Recorded as the attempt it was — the only record a denial leaves. Never decided | | A subagent starts | Given the parent run's pins and a recalled block of its own — the only memory it will ever see | @@ -392,7 +392,7 @@ unset it raises a config error rather than quietly deriving a different run. `MUBIT_CC_SESSION_END_DETACH`, default `1`. Codex clamps a `SessionEnd` hook to three seconds and kills it there, whatever the registration asks for. The end-of-session drain and the -reflect — the only thing promoting a lesson beyond its run — do not reliably fit, so the hook +reflect — which carries lessons into later sessions — do not reliably fit, so the hook hands them to a detached process. Turning this off costs you reflections. A detached child can still be reaped with the terminal; if that matters, run `mubit-memory:reflect` at the end of a long session. @@ -417,6 +417,19 @@ command. It only ever warns — it never allows, denies or rewrites, on any path whether the feature is on or not, which is why `setup` leaves the registration out unless you pass `--with-pre-tool`. +### Crediting memory, and the scorecard — mostly off here + +Every injected memory line now starts with a short id such as `[m7k2q]`, which `mubit_outcome` +accepts and the plugin maps back to the entry's reference id. `MUBIT_CC_OUTCOME_REVIEW`, +default `nudge` under Codex, adds one sentence to the memory block asking the model to credit +what helped or misled it before finishing; `off` drops it. `stop` also has the Stop hook ask +for a short review once per turn — the default in Claude Code, but a Stop continuation has not +been verified under Codex. + +`MUBIT_CC_SESSION_SCORE`, default `off` under Codex, prints the memory scorecard under each +reply that showed a lesson (`full` or `compact`). The +[Claude Code guide](../../claude-code/docs/user-guide.md#the-session-scorecard) explains both. + ### Quieting it temporarily ```bash diff --git a/integrations/codex/hooks/dist/impl/capture.mjs b/integrations/codex/hooks/dist/impl/capture.mjs index c8296b3..c1d28d9 100755 --- a/integrations/codex/hooks/dist/impl/capture.mjs +++ b/integrations/codex/hooks/dist/impl/capture.mjs @@ -406,6 +406,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -479,6 +489,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -624,7 +636,7 @@ var init_config = __esm({ ]; CACHE_FILE2 = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION2 = 3; + CACHE_VERSION2 = 4; MAX_ENV_TAGS = 8; MODE = "hosted"; LANG_FILES = [ @@ -667,6 +679,7 @@ function str2(v) { var CHUNK_BYTES, MAX_LINE_BYTES, TEXT_BLOCKS, MAX_CONTENT_DEPTH; var init_transcript = __esm({ "../claude-code/lib/transcript.mjs"() { + init_codex_rollout(); CHUNK_BYTES = 256 * 1024; MAX_LINE_BYTES = 2 * 1024 * 1024; TEXT_BLOCKS = /* @__PURE__ */ new Set(["text", "input_text", "output_text"]); @@ -676,13 +689,27 @@ var init_transcript = __esm({ // ../claude-code/lib/codex-rollout.mjs import { openSync as openSync2, readSync, closeSync as closeSync2, fstatSync } from "node:fs"; -function isInjectedUserText(text) { - return typeof text === "string" && INJECTED_USER_RE.test(text); -} -function stripInjectedBlocks(content) { - if (typeof content === "string") return isInjectedUserText(content) ? "" : content; +function isHookFeedback(text) { + const end = text.trimEnd().length; + let at = 0; + do { + HOOK_PROMPT_OPEN_RE.lastIndex = at; + if (!HOOK_PROMPT_OPEN_RE.test(text)) return false; + const close = text.indexOf(HOOK_PROMPT_CLOSE, HOOK_PROMPT_OPEN_RE.lastIndex); + if (close === -1) return false; + at = close + HOOK_PROMPT_CLOSE.length; + } while (at < end); + return true; +} +function isInjectedUserText(text, opts) { + if (typeof text !== "string") return false; + if (INJECTED_USER_RE.test(text) || isHookFeedback(text)) return true; + return !opts?.keepUserActions && USER_ACTION_RE.test(text); +} +function stripInjectedBlocks(content, opts) { + if (typeof content === "string") return isInjectedUserText(content, opts) ? "" : content; if (!Array.isArray(content)) return content; - return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text))); + return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text, opts))); } function toolCallRecord(transcriptPath, toolUseId, opts = {}) { const path = typeof transcriptPath === "string" ? transcriptPath.trim() : ""; @@ -706,7 +733,7 @@ function toolCallRecord(transcriptPath, toolUseId, opts = {}) { const exitCode = Number.isFinite(item2.exit_code) ? Number(item2.exit_code) : null; return { found: true, - // § `status` is the host's own verdict and is what to trust. `exit_code` is the + // `status` is the host's own verdict and is what to trust. `exit_code` is the // fallback for a record shape that carries one without the other. failed: status ? status === "failed" : exitCode !== null && exitCode !== 0, exitCode, @@ -787,14 +814,17 @@ function readHead(path, bytes) { } } } -var TAIL_BYTES, HEAD_BYTES, MAX_PROMPT_CHARS, INJECTED_USER_RE, NOT_FOUND; +var TAIL_BYTES, HEAD_BYTES, MAX_PROMPT_CHARS, INJECTED_USER_RE, USER_ACTION_RE, HOOK_PROMPT_OPEN_RE, HOOK_PROMPT_CLOSE, NOT_FOUND; var init_codex_rollout = __esm({ "../claude-code/lib/codex-rollout.mjs"() { init_transcript(); TAIL_BYTES = 512 * 1024; HEAD_BYTES = 256 * 1024; MAX_PROMPT_CHARS = 4096; - INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|turn_aborted|user_shell_command|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; + INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; + USER_ACTION_RE = /^\s*<(?:turn_aborted|user_shell_command)\b/; + HOOK_PROMPT_OPEN_RE = /\s*]*)?>/y; + HOOK_PROMPT_CLOSE = ""; NOT_FOUND = Object.freeze({ found: false, failed: false, @@ -872,9 +902,9 @@ function scrub(text, count) { } function entropy(s) { if (s === null || s === void 0) return 0; - const str6 = typeof s === "string" ? s : String(s); - if (str6.length === 0) return 0; - const buf = Buffer.from(str6, "utf8"); + const str8 = typeof s === "string" ? s : String(s); + if (str8.length === 0) return 0; + const buf = Buffer.from(str8, "utf8"); const n = buf.length; if (n === 0) return 0; const counts = new Uint32Array(256); @@ -1705,7 +1735,59 @@ function classifyTurn(prompt, lastAssistantMessage, opts = {}) { agentType: isSubagent && typeof o.agent_type === "string" ? o.agent_type : "" }; } -var CONTENT_TYPE, TOOL_TABLE, FALLBACK, MCP, FAILURE, MCP_PREFIX; +function toolIntent(toolName, toolInput) { + try { + const name = typeof toolName === "string" ? toolName.trim() : ""; + if (!name) return "other"; + if (READ_TOOLS.has(name)) return "read"; + if (SEARCH_TOOLS.has(name)) return "search"; + if (WRITE_TOOLS.has(name)) return "write"; + if (SHELL_TOOLS.has(name)) return shellIntent(toolInput); + const own = OWN_MCP_PREFIXES2.find((p) => name.startsWith(p)); + if (own && OWN_READ_TOOLS.has(name.slice(own.length))) return "read"; + return "other"; + } catch { + return "other"; + } +} +function shellIntent(input) { + const raw = input && typeof input === "object" ? input.command : void 0; + const cmd = typeof raw === "string" ? raw : Array.isArray(raw) ? raw.filter((s) => typeof s === "string").join(" ") : ""; + if (!cmd.trim() || cmd.length > MAX_SHELL_PARSE) return "exec"; + const cleaned = cmd.replace(/&>\s*\/dev\/null/g, " ").replace(/\d*>>?\s*\/dev\/null/g, " ").replace(/\d*>&\d+/g, " "); + if (/[<>]/.test(cleaned.replace(/<< t === "--in-place" || /^-[a-zA-Z]*i/.test(t))) return "exec"; + if (!READ_VERBS.has(verb)) return "exec"; + } + return search ? "search" : "read"; +} +function gitSubcommand(tokens) { + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t === "-C" || t === "-c") { + i++; + continue; + } + if (t.startsWith("-")) continue; + return t; + } + return ""; +} +var CONTENT_TYPE, TOOL_TABLE, FALLBACK, MCP, FAILURE, MCP_PREFIX, READ_TOOLS, SEARCH_TOOLS, WRITE_TOOLS, SHELL_TOOLS, OWN_MCP_PREFIXES2, OWN_READ_TOOLS, SEARCH_VERBS, READ_VERBS, READ_GIT, MAX_SHELL_PARSE; var init_classify = __esm({ "../claude-code/lib/classify.mjs"() { CONTENT_TYPE = "text"; @@ -1719,7 +1801,7 @@ var init_classify = __esm({ Write: ["trace", "medium"], MultiEdit: ["trace", "medium"], NotebookEdit: ["trace", "medium"], - // Shell — also subject to §4.4 self-reference suppression, which runs upstream of this + // Shell — also subject to self-reference suppression, which runs upstream of this // module in `capture.mjs` step 2. Bash: ["tool_output", "low"], BashOutput: ["tool_output", "low"], @@ -1773,6 +1855,62 @@ var init_classify = __esm({ FAILURE = /** @type {[string, string]} */ ["trace", "high"]; MCP_PREFIX = "mcp__"; + READ_TOOLS = /* @__PURE__ */ new Set([ + "Read", + "NotebookRead", + "WebFetch", + "LSP", + "TaskOutput", + "BashOutput", + "ToolSearch", + "ListMcpResourcesTool", + "ReadMcpResourceTool", + "view_image" + ]); + SEARCH_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob", "WebSearch", "web_search"]); + WRITE_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"]); + SHELL_TOOLS = /* @__PURE__ */ new Set(["Bash", "shell", "exec_command", "write_stdin", "KillShell"]); + OWN_MCP_PREFIXES2 = ["mcp__plugin_mubit-memory_mubit__", "mcp__mubit__"]; + OWN_READ_TOOLS = /* @__PURE__ */ new Set([ + "mubit_recall", + "mubit_dereference", + "mubit_status", + "mubit_memory_health", + "mubit_diagnose" + ]); + SEARCH_VERBS = /* @__PURE__ */ new Set(["grep", "rg", "ag", "ack", "find", "fd"]); + READ_VERBS = /* @__PURE__ */ new Set([ + "ls", + "cat", + "head", + "tail", + "less", + "wc", + "stat", + "file", + "which", + "type", + "echo", + "printf", + "pwd", + "tree", + "du", + "df", + "diff", + "cmp", + "test", + "[", + "jq", + "sort", + "uniq", + "cut", + "tr", + "nl", + "sed", + "awk" + ]); + READ_GIT = /* @__PURE__ */ new Set(["status", "diff", "log", "show", "grep", "rev-parse", "branch", "ls-files", "blame"]); + MAX_SHELL_PARSE = 4096; } }); @@ -1780,15 +1918,50 @@ var init_classify = __esm({ function decideOutcome(turn) { if (!isObject4(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str3(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str3(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject4(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str3(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str3(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -1798,21 +1971,44 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str3(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + } + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str3(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject4(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject4(v)) out[ref] = /** @type {any} */ + v; + } + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject4(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -2179,19 +2375,646 @@ var init_filechange = __esm({ } }); +// ../claude-code/lib/handles.mjs +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function resolveHandles(ids, knownRefs) { + const byHandle = /* @__PURE__ */ new Map(); + for (const ref of Array.isArray(knownRefs) ? knownRefs : []) { + const h = handleFor(ref); + if (h) byHandle.set(h, ref); + } + const out = []; + const unresolved = []; + for (const raw of Array.isArray(ids) ? ids : []) { + if (typeof raw !== "string") continue; + const bare = bareOf(raw); + if (BARE_RE.test(bare)) { + const ref = byHandle.get(bare); + if (ref) out.push(ref); + else { + out.push(bare); + unresolved.push(bare); + } + } else if (raw.trim()) { + out.push(raw.trim()); + } + } + return { ids: out, unresolved }; +} +function knownRefsFromRows(rows) { + const last = /* @__PURE__ */ new Map(); + let n = 0; + const note = (ref) => { + if (typeof ref === "string" && ref.trim()) last.set(ref.trim(), n++); + }; + for (const row of Array.isArray(rows) ? rows : []) { + if (!row || typeof row !== "object") continue; + if (row.kind !== "start" && row.kind !== "shown" && row.kind !== "refs") continue; + if (row.lessons && typeof row.lessons === "object") for (const ref of Object.keys(row.lessons)) note(ref); + if (Array.isArray(row.refs)) for (const ref of row.refs) note(ref); + } + return [...last.entries()].sort((a, b) => a[1] - b[1]).map(([ref]) => ref); +} +function bareOf(v) { + const s = typeof v === "string" ? v.trim() : ""; + return s.startsWith("[") && s.endsWith("]") ? s.slice(1, -1).trim() : s; +} +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + +// ../claude-code/lib/review.mjs +function reviewCandidates(lessons, opts = {}) { + try { + const max = Number(opts?.max) > 0 ? Math.trunc(Number(opts.max)) : DEFAULT_MAX; + const keep = (Array.isArray(lessons) ? lessons : []).filter((l) => l && typeof l === "object" && str5(l.ref) && str5(l.handle) && !VERDICTS.has(str5(l.explicit).toLowerCase()) && (l.pointer !== true || l.used === true)); + const used = keep.filter((l) => l.used === true); + const rest = keep.filter((l) => l.used !== true); + return [...used, ...rest].slice(0, max); + } catch { + return []; + } +} +function shouldReview(o) { + if (!o || typeof o !== "object") return false; + if (str5(o.outcomeReview) !== "stop") return false; + if (str5(o.outcomeMode) === "off") return false; + if (o.stopHookActive === true) return false; + if (o.alreadyRequested === true) return false; + if (str5(o.apiError)) return false; + if (o.isSubagent === true) return false; + return Array.isArray(o.candidates) && o.candidates.length > 0; +} +function reviewReason(candidates) { + const list2 = (Array.isArray(candidates) ? candidates : []).filter((l) => l && typeof l === "object" && str5(l.handle)); + if (!list2.length) return ""; + const lines = ["Mubit memory review (once per turn). Lessons in your context this turn:"]; + for (const l of list2) { + lines.push(`- [${oneLine(l.handle, 16)}] ${oneLine(l.title, MAX_TITLE_CHARS)}${l.used === true ? " (your reply appears to use it)" : ""}`); + } + lines.push( + 'Call mubit_outcome with reference_id "global", outcome "success" and entry_ids set to the ids that helped; for any that were wrong or misled you, a second call with outcome "failure" and a one-line rationale. Skip the rest.', + "If a lesson was wrong or incomplete, also save a corrected one with mubit_learned.", + 'Then end with one short line starting "Memory review:" naming what you credited; do not repeat your answer.' + ); + return lines.join("\n"); +} +function stripReviewLine(text) { + return typeof text === "string" ? text.replace(REVIEW_LINE, "").replace(/\n{3,}/g, "\n\n").trim() : ""; +} +function str5(v) { + return typeof v === "string" ? v.trim() : ""; +} +function oneLine(v, max) { + const s = str5(v).replace(/\s+/g, " "); + return s.length > max ? `${s.slice(0, max)}\u2026` : s; +} +var DEFAULT_MAX, VERDICTS, MAX_TITLE_CHARS, REVIEW_LINE; +var init_review = __esm({ + "../claude-code/lib/review.mjs"() { + DEFAULT_MAX = 6; + VERDICTS = /* @__PURE__ */ new Set(["success", "failure", "partial", "neutral"]); + MAX_TITLE_CHARS = 64; + REVIEW_LINE = /^[ \t]*(?:[-*>][ \t]+)?[*_]{0,2}memory review[*_]{0,2}:.*$/gim; + } +}); + +// ../claude-code/lib/scorecard.mjs +function foldScorecard(rows, currentPromptId) { + const list2 = Array.isArray(rows) ? rows.filter(isObject7) : []; + const order = []; + const prompts = /* @__PURE__ */ new Map(); + const shown = /* @__PURE__ */ new Map(); + const tools = /* @__PURE__ */ new Map(); + const explicit = /* @__PURE__ */ new Map(); + const turns = /* @__PURE__ */ new Map(); + const clears = []; + let learned = 0; + let tokens = 0; + let pendingStanding = null; + const seePrompt = (id, pos) => { + if (!prompts.has(id)) { + prompts.set(id, { ...markOf(pos), standing: null }); + order.push(id); + } + return ( + /** @type {any} */ + prompts.get(id) + ); + }; + list2.forEach((row, pos) => { + const id = str6(row.prompt_id); + switch (row.kind) { + case "start": + tokens += num2(row.tokens); + pendingStanding = isObject7(row.lessons) ? row.lessons : {}; + if (str6(row.source) === "clear") clears.push(pos); + break; + case "prompt": { + if (!id) break; + const p = seePrompt(id, pos); + flagPrompt(p, row); + if (!p.slash && pendingStanding) { + p.standing = pendingStanding; + pendingStanding = null; + } + break; + } + case "shown": + if (!id) break; + seePrompt(id, pos); + tokens += num2(row.tokens); + if (isObject7(row.lessons)) shown.set(id, { ...shown.get(id) ?? {}, ...row.lessons }); + break; + case "tool": + if (!id) break; + tools.set(id, [...tools.get(id) ?? [], { intent: str6(row.intent), failed: row.failed === true }]); + break; + case "explicit": { + if (!id || !Array.isArray(row.ids)) break; + const m = explicit.get(id) ?? /* @__PURE__ */ new Map(); + for (const ref of row.ids) if (typeof ref === "string" && ref) m.set(ref, str6(row.outcome).toLowerCase()); + explicit.set(id, m); + break; + } + case "learned": + learned++; + break; + case "turn": + if (!id) break; + seePrompt(id, pos); + turns.set(id, row); + break; + default: + break; + } + }); + const turnIds = order.filter((id) => !prompts.get(id)?.slash); + const ordinal = new Map(turnIds.map((id, i) => [id, i + 1])); + const byLesson = /* @__PURE__ */ new Map(); + let lessonPrompts = 0; + let thisTurn = { promptId: str6(currentPromptId), shown: 0, used: [], checkable: false, lessons: [] }; + turnIds.forEach((id, i) => { + const lessons = lessonsOf(prompts.get(id)?.standing ?? null, shown.get(id) ?? null); + const refs = Object.keys(lessons); + if (refs.length) lessonPrompts++; + const turn = turns.get(id) ?? null; + const verdicts = explicit.get(id) ?? /* @__PURE__ */ new Map(); + const next = turnIds[i + 1] ?? ""; + const views = []; + const usedTitles = []; + let checkable = false; + for (const ref of refs) { + const l = lessons[ref]; + const echo = turn && !str6(turn.api_error) && isObject7(turn.lessons) && isObject7(turn.lessons[ref]) ? triState(turn.lessons[ref].used) : null; + const ex = verdicts.get(ref) ?? ""; + let state = echo === true ? "used" : echo === false ? "not" : "unknown"; + let verdict = ""; + if (ex === "success" || ex === "partial") { + state = "used"; + verdict = "worked"; + } else if (ex === "failure") { + state = "used"; + verdict = "failed"; + } else if (ex === "neutral") state = "used"; + if (state === "used" && !verdict) verdict = settle(id, next); + const entry = byLesson.get(ref) ?? { title: "", times: 0, states: [], verdicts: [] }; + entry.title = l.title || entry.title; + entry.times++; + entry.states.push(state); + if (state === "used") entry.verdicts.push({ v: verdict, prompt: num2(ordinal.get(id)) }); + byLesson.set(ref, entry); + if (state !== "unknown") checkable = true; + if (state === "used") usedTitles.push(l.title); + views.push({ + ref, + handle: l.handle, + title: l.title, + pointer: l.pointer, + used: echo, + explicit: ex + }); + } + if (id === str6(currentPromptId)) { + thisTurn = { promptId: id, shown: refs.length, used: usedTitles, checkable, lessons: views }; + } + }); + const counts = { shown: 0, used: 0, notUsed: 0, unknown: 0, worked: 0, failed: 0, waiting: 0 }; + const failed = []; + let neverUsed = 0; + for (const [ref, l] of byLesson) { + counts.shown++; + if (l.states.includes("used")) { + counts.used++; + const bad = l.verdicts.filter((v) => v.v === "failed"); + if (bad.length) { + counts.failed++; + failed.push({ ref, title: l.title, prompt: Math.max(...bad.map((v) => v.prompt)) }); + } else if (l.verdicts.some((v) => v.v === "waiting")) counts.waiting++; + else counts.worked++; + } else if (l.states.includes("not")) { + counts.notUsed++; + if (l.times >= NEVER_USED_AFTER) neverUsed++; + } else { + counts.unknown++; + } + } + failed.sort((a, b) => b.prompt - a.prompt); + return { + prompts: turnIds.length, + lessonPrompts, + learned, + tokens, + lessons: counts, + thisTurn, + review: { failed, neverUsed } + }; + function settle(id, next) { + const nextPrompt = next ? prompts.get(next) : null; + const from = num2(prompts.get(id)?.pos); + const to = num2(nextPrompt?.pos); + if (nextPrompt?.correction && !clears.some((c) => c > from && c < to)) return "failed"; + const acting = (tools.get(id) ?? []).filter((t) => !READ_ONLY_INTENTS.has(t.intent)); + if (acting.length && acting[acting.length - 1].failed) return "failed"; + return nextPrompt ? "worked" : "waiting"; + } +} +function markOf(pos) { + return { slash: false, correction: false, pos, flagged: false }; +} +function flagPrompt(p, row) { + if (p.flagged) return; + p.flagged = true; + p.slash = row.slash === true; + p.correction = row.correction === true; +} +function renderScorecard(summary, mode) { + if (!isObject7(summary) || !summary.thisTurn || num2(summary.thisTurn.shown) <= 0) return ""; + if (mode !== "full" && mode !== "compact") return ""; + const s = ( + /** @type {ScoreSummary} */ + summary + ); + const l = s.lessons; + const verdicts = [ + l.worked ? `${l.worked} worked` : "", + l.failed ? `${l.failed} failed` : "", + l.waiting ? `${l.waiting} waiting` : "" + ].filter(Boolean); + const tail = [ + s.learned ? `+${s.learned} learned` : "", + s.tokens ? `memory added ${formatTokens(s.tokens)} tok` : "" + ].filter(Boolean); + const head = `mubit \xB7 this session \xB7 lessons on ${s.lessonPrompts} of ${s.prompts} ${plural(s.prompts, "prompt")}`; + if (mode === "compact") { + return [head, `${l.used} of ${l.shown} ${plural(l.shown, "lesson")} used`, ...verdicts, ...tail].join(DOT); + } + const lines = [[head, ...tail].join(DOT), ` ${l.shown} ${plural(l.shown, "lesson")} shown`]; + const usedDetail = verdicts.map((v) => v.endsWith(" waiting") ? `${v} on your reply` : v).join(DOT); + const usedLabel = `${l.used} used`; + const tree = [ + l.used ? `${usedLabel.padEnd(Math.max(USED_LABEL_WIDTH, usedLabel.length + 2))}${usedDetail}` : "", + l.notUsed ? `${l.notUsed} not used` : "", + l.unknown ? `${l.unknown} unknown` : "" + ].filter(Boolean); + tree.forEach((row, i) => lines.push(` ${i === tree.length - 1 ? "\u2514" : "\u251C"} ${row}`)); + const t = s.thisTurn; + if (t.used.length) { + const named = t.used.slice(0, MAX_TURN_TITLES).map(quote).join(", "); + const more = t.used.length - MAX_TURN_TITLES; + lines.push(` this turn: used ${named}${more > 0 ? ` +${more} more` : ""}`); + } else { + lines.push(` this turn: shown ${t.shown}, none ${t.checkable ? "used" : "checkable"}`); + } + const review = []; + const failed = s.review?.failed ?? []; + if (failed.length) { + review.push(`${quote(failed[0].title)} failed on prompt ${failed[0].prompt}`); + if (failed.length > 1) review.push(`+${failed.length - 1} more failed`); + } + const never = num2(s.review?.neverUsed); + if (never) review.push(`${never} ${plural(never, "lesson")} shown ${NEVER_USED_AFTER}+ times and never used`); + if (review.length) lines.push(` review: ${review.join(DOT)}`); + return lines.join("\n"); +} +function lessonsOf(standing, recalled) { + const out = {}; + for (const [ref, v] of Object.entries(standing ?? {})) { + if (!ref || !isObject7(v)) continue; + out[ref] = { title: str6(v.title), handle: str6(v.handle), pointer: false }; + } + for (const [ref, v] of Object.entries(recalled ?? {})) { + if (!ref || !isObject7(v)) continue; + const prev = out[ref]; + out[ref] = { + title: str6(v.title) || prev?.title || "", + handle: str6(v.handle) || prev?.handle || "", + pointer: v.pointer === true && (prev ? prev.pointer : true) + }; + } + return out; +} +function formatTokens(n) { + const t = Math.max(0, Math.trunc(num2(n))); + return t >= 1e3 ? `${(t / 1e3).toFixed(1)}k` : String(t); +} +function plural(n, word) { + return n === 1 ? word : `${word}s`; +} +function quote(title) { + return `"${String(title ?? "").replace(/"/g, "'")}"`; +} +function triState(v) { + return v === true ? true : v === false ? false : null; +} +function isObject7(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} +function str6(v) { + return typeof v === "string" ? v : ""; +} +function num2(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} +var DOT, MAX_TURN_TITLES, NEVER_USED_AFTER, READ_ONLY_INTENTS, USED_LABEL_WIDTH; +var init_scorecard = __esm({ + "../claude-code/lib/scorecard.mjs"() { + DOT = " \xB7 "; + MAX_TURN_TITLES = 2; + NEVER_USED_AFTER = 3; + READ_ONLY_INTENTS = /* @__PURE__ */ new Set(["read", "search"]); + USED_LABEL_WIDTH = 12; + } +}); + +// ../claude-code/lib/scorecard-log.mjs +import { + closeSync as closeSync4, + fstatSync as fstatSync3, + openSync as openSync4, + readdirSync as readdirSync3, + readFileSync as readFileSync7, + readSync as readSync3, + statSync as statSync6, + writeSync as writeSync4 +} from "node:fs"; +import { dirname as dirname8, join as join11 } from "node:path"; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join11(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname8(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync4(p, "a+"); + try { + const st = fstatSync3(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync3(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync4(fd, prefix + line); + } finally { + closeSync4(fd); + } + return true; + } catch { + return false; + } +} +function readScoreRows(cfg, sessionId, opts = {}) { + const p = scorecardPath(cfg, sessionId); + if (!p) return []; + return readRowsAt(p, opts); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync6(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync4(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync3(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync4(fd); + } + partialHead = true; + } else { + text = readFileSync7(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} +var SCORE_LOG_VERSION, SCORE_DIR, SCORE_LOG_TTL_MS, MAX_READ_BYTES, MAX_ID; +var init_scorecard_log = __esm({ + "../claude-code/lib/scorecard-log.mjs"() { + init_state(); + SCORE_LOG_VERSION = 1; + SCORE_DIR = "scorecard"; + SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + MAX_READ_BYTES = 4 * 1024 * 1024; + MAX_ID = 128; + } +}); + +// ../claude-code/lib/assemble.mjs +var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS; +var init_assemble = __esm({ + "../claude-code/lib/assemble.mjs"() { + init_handles(); + SECTION_KEYS = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "archive_blocks", + "handoffs", + "feedback", + "facts", + "observations", + "working_memory", + "traces", + "goals", + "checkpoints", + "logs", + "other" + ]); + EMISSION_ORDER = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "facts", + "observations", + "working_memory", + "traces", + "goals" + ]); + RENDER_ORDER = Object.freeze([ + ...EMISSION_ORDER, + ...SECTION_KEYS.filter((k) => !EMISSION_ORDER.includes(k)) + ]); + SECTION_BY_ENTRY_TYPE = Object.freeze({ + mental_model: "mental_models", + rule: "active_rules", + lesson: "lessons", + fact: "facts", + observation: "observations", + working_memory: "working_memory", + goal: "working_memory", + trace: "traces", + tool_output: "traces", + tool_input: "traces", + task_result: "traces", + step_outcome: "traces", + archive_block: "archive_blocks", + checkpoint: "checkpoints", + handoff: "handoffs", + feedback: "feedback" + }); + HEADINGS = Object.freeze({ + mental_models: "Mental models", + active_rules: "Active rules", + lessons: "Lessons", + archive_blocks: "Archive blocks", + handoffs: "Handoffs", + feedback: "Feedback", + facts: "Facts", + observations: "Observations", + working_memory: "Working memory", + traces: "Traces", + goals: "Goals", + checkpoints: "Checkpoints", + logs: "Logs", + other: "Other" + }); + } +}); + +// ../claude-code/lib/terms.mjs +function termSet(s) { + const set = /* @__PURE__ */ new Set(); + for (const m of String(s ?? "").matchAll(TERM_RE)) set.add(m[0].toLowerCase()); + return set; +} +function matchTerms(terms, text) { + return matchIn(haystackOf(text), terms); +} +function evaluateUse(entries, reply, opts = {}) { + const out = {}; + try { + const exclude = new Set([...opts?.exclude ?? []].filter((t) => typeof t === "string").map((t) => t.toLowerCase())); + const list2 = []; + for (const e of Array.isArray(entries) ? entries : []) { + if (!e || typeof e !== "object" || typeof e.ref !== "string" || !e.ref) continue; + const terms = [...new Set((Array.isArray(e.terms) ? e.terms : []).filter((t) => typeof t === "string" && t.length >= TERM_MIN && t.length <= TERM_MAX).map((t) => t.toLowerCase()))]; + list2.push({ ref: e.ref, terms }); + } + const owners = /* @__PURE__ */ new Map(); + for (const e of list2) for (const t of e.terms) owners.set(t, (owners.get(t) ?? 0) + 1); + const text = typeof reply === "string" ? reply : ""; + const hay = text.trim() ? haystackOf(text.slice(0, MAX_ANSWER_SCAN)) : ""; + for (const e of list2) { + const candidates = e.terms.filter((t) => owners.get(t) === 1 && !exclude.has(t)); + const n = candidates.length; + if (n === 0) { + out[e.ref] = { used: null, matched: [], candidates: 0, reason: "no_distinct_terms" }; + continue; + } + if (!hay) { + out[e.ref] = { used: null, matched: [], candidates: n, reason: "no_reply" }; + continue; + } + const matched = matchIn(hay, candidates); + const threshold = n <= 2 ? n : Math.max(2, Math.ceil(n / 4)); + out[e.ref] = { used: matched.length >= threshold, matched: matched.slice(0, MAX_MATCHED), candidates: n }; + } + } catch { + } + return out; +} +function haystackOf(text) { + return ` ${String(text ?? "").toLowerCase().replace(/[^a-z0-9_]+/g, " ")} `; +} +function matchIn(hay, terms) { + return terms.filter((t) => hay.includes(` ${t}`)); +} +var TERM_RE, MAX_PROMPT_SCAN, MAX_ANSWER_SCAN, TERM_MIN, TERM_MAX, MAX_MATCHED; +var init_terms = __esm({ + "../claude-code/lib/terms.mjs"() { + init_assemble(); + init_handles(); + init_redact(); + TERM_RE = /[A-Za-z][A-Za-z0-9_]{3,23}/g; + MAX_PROMPT_SCAN = 16 * 1024; + MAX_ANSWER_SCAN = 64 * 1024; + TERM_MIN = 4; + TERM_MAX = 24; + MAX_MATCHED = 12; + } +}); + // ../claude-code/lib/runid.mjs import { spawnSync as spawnSync3 } from "node:child_process"; import { createHash as createHash2 } from "node:crypto"; -import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync6 } from "node:fs"; -import { basename as basename2, dirname as dirname8, join as join11, resolve as resolve6 } from "node:path"; +import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync7 } from "node:fs"; +import { basename as basename2, dirname as dirname9, join as join12, resolve as resolve6 } from "node:path"; function agentRole(env = process.env) { const host2 = typeof env?.MUBIT_CC_HOST === "string" ? env.MUBIT_CC_HOST.trim().toLowerCase() : ""; return AGENT_ROLES[host2] ?? DEFAULT_AGENT_ROLE; } function deriveRunId(cfg, payload = {}, options = {}) { - const c = isObject7(cfg) ? cfg : {}; - const p = isObject7(payload) ? payload : {}; - const persist = !(isObject7(options) && options.persist === false); + const c = isObject8(cfg) ? cfg : {}; + const p = isObject8(payload) ? payload : {}; + const persist = !(isObject8(options) && options.persist === false); return assertUsableRunId(resolveRunId(c, p, persist)); } function resolveRunId(cfg, payload, persist) { @@ -2260,7 +3083,7 @@ function directoryRunId(cfg, payload, withBranch) { return branch ? `cc-${slug}-${branch}-${digest}` : `cc-${slug}-${digest}`; } function reusableRun(cfg, payload, prev, strategy) { - if (!isObject7(prev)) return ""; + if (!isObject8(prev)) return ""; const id = typeof prev.run_id === "string" ? prev.run_id.trim() : ""; if (!id || FORBIDDEN_RUN_IDS.has(id.toLowerCase())) return ""; const recorded = typeof prev.strategy === "string" ? prev.strategy.trim() : ""; @@ -2279,13 +3102,13 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; } function turnKey(payload) { - if (!isObject7(payload)) return ""; + if (!isObject8(payload)) return ""; const prompt = typeof payload.prompt_id === "string" ? payload.prompt_id.trim() : ""; if (prompt) return prompt; const turn = typeof payload.turn_id === "string" ? payload.turn_id.trim() : ""; @@ -2297,7 +3120,7 @@ function turnNumber(cfg, runId, payload) { try { const id = safeSegment(turnKey(payload)); if (!id) return 0; - const staged = readJson(join11(runDir(cfg, runId), "turns", `${id}.json`), null); + const staged = readJson(join12(runDir(cfg, runId), "turns", `${id}.json`), null); const n = Number(staged?.turn_number); return Number.isFinite(n) && n > 0 ? n : 0; } catch { @@ -2305,7 +3128,7 @@ function turnNumber(cfg, runId, payload) { } } function deriveAgentId(payload = {}) { - const p = isObject7(payload) ? payload : {}; + const p = isObject8(payload) ? payload : {}; const role = agentRole(); const sub = subagentShort(p); return sub ? `${role}-sub-${sub}` : role; @@ -2332,7 +3155,7 @@ function loadSessionMap(sessionId) { const file = sessionFileName(sessionId); if (!file) return null; const stored = readJson(sessionPath(file), null); - return isObject7(stored) ? stored : null; + return isObject8(stored) ? stored : null; } catch { return null; } @@ -2340,10 +3163,10 @@ function loadSessionMap(sessionId) { function rememberRun(cfg, payload, sessionId, prev, next) { const now = Date.now(); const isSessionStart = !!next.source || payload.hook_event_name === "SessionStart"; - const moved = !isObject7(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; - const lastSeen = isObject7(prev) ? numberOr2(prev.last_seen_at, 0) : 0; + const moved = !isObject8(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; + const lastSeen = isObject8(prev) ? numberOr2(prev.last_seen_at, 0) : 0; if (!moved && !isSessionStart && now - lastSeen < TOUCH_INTERVAL_MS) return; - const inherited = isObject7(prev) ? prev : {}; + const inherited = isObject8(prev) ? prev : {}; const dir = projectDirOf(cfg, payload); saveSessionMap(sessionId, { ...inherited, @@ -2377,7 +3200,7 @@ function normaliseRecord(record) { clear_count: 0, endpoint_hash: "" }; - if (isObject7(record)) { + if (isObject8(record)) { for (const [k, v] of Object.entries(record)) { if (v !== void 0) out[k] = v; } @@ -2385,7 +3208,7 @@ function normaliseRecord(record) { return out; } function sessionPath(file) { - return join11(dataDir({}), "sessions", `${file}.json`); + return join12(dataDir({}), "sessions", `${file}.json`); } function sessionFileName(sessionId) { const raw = typeof sessionId === "string" ? sessionId.trim() : ""; @@ -2394,10 +3217,10 @@ function sessionFileName(sessionId) { return safe && safe !== "." && safe !== ".." ? safe : ""; } function projectDirOf(cfg, payload = {}) { - return usableDir(isObject7(payload) ? payload.cwd : "") || firstString2(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); + return usableDir(isObject8(payload) ? payload.cwd : "") || firstString2(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); } function resolveProjectDir(cfg, payload = {}) { - return projectDirOf(isObject7(cfg) ? cfg : {}, payload); + return projectDirOf(isObject8(cfg) ? cfg : {}, payload); } function projectRootOf(dir) { return gitToplevel2(dir) || dir; @@ -2406,7 +3229,7 @@ function usableDir(v) { const s = typeof v === "string" ? v.trim() : ""; if (!s) return ""; try { - return statSync6(s).isDirectory() ? s : ""; + return statSync7(s).isDirectory() ? s : ""; } catch { return ""; } @@ -2436,8 +3259,8 @@ function hasGitDir2(start) { try { let cur = resolve6(start); for (let i = 0; i < 24; i++) { - if (existsSync7(join11(cur, ".git"))) return true; - const up = dirname8(cur); + if (existsSync7(join12(cur, ".git"))) return true; + const up = dirname9(cur); if (up === cur) return false; cur = up; } @@ -2445,7 +3268,7 @@ function hasGitDir2(start) { } return false; } -function isObject7(v) { +function isObject8(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function firstString2(...vals) { @@ -2459,7 +3282,7 @@ function numberOr2(v, d) { return Number.isFinite(n) ? n : d; } function clearCount(rec) { - if (!isObject7(rec)) return 0; + if (!isObject8(rec)) return 0; const n = Math.trunc(numberOr2(rec.clear_count, 0)); return n > 0 ? n : 0; } @@ -2472,7 +3295,7 @@ function normaliseSource(v) { return SOURCES.has(s) ? s : ""; } function hostSessionId(payload) { - const v = isObject7(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; + const v = isObject8(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; if (!v || PLACEHOLDER_SESSION_IDS.has(v.toLowerCase())) return ""; return v; } @@ -2521,30 +3344,30 @@ var init_runid = __esm({ // ../claude-code/lib/spool.mjs import { - closeSync as closeSync4, + closeSync as closeSync5, existsSync as existsSync8, linkSync, - openSync as openSync4, - readdirSync as readdirSync4, - readFileSync as readFileSync8, + openSync as openSync5, + readdirSync as readdirSync5, + readFileSync as readFileSync9, renameSync as renameSync4, - statSync as statSync7, + statSync as statSync8, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4, - writeSync as writeSync4 + writeSync as writeSync5 } from "node:fs"; import { createHash as createHash3, randomBytes } from "node:crypto"; -import { join as join12 } from "node:path"; +import { join as join13 } from "node:path"; function spoolDir(cfg, runId) { - return join12(runDir(cfg, runId), "spool"); + return join13(runDir(cfg, runId), "spool"); } function rand6() { let s = ""; try { const b = randomBytes(6); - for (let i = 0; i < 6; i++) s += ALPHABET[b[i] % ALPHABET.length]; + for (let i = 0; i < 6; i++) s += ALPHABET2[b[i] % ALPHABET2.length]; } catch { - for (let i = s.length; i < 6; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + for (let i = s.length; i < 6; i++) s += ALPHABET2[Math.floor(Math.random() * ALPHABET2.length)]; } return s; } @@ -2561,7 +3384,7 @@ function appendItem(cfg, runId, item2) { } if (typeof body !== "string") return ""; for (let attempt2 = 0; attempt2 < 8; attempt2++) { - const target = join12(dir, `${Date.now()}-${rand6()}.json`); + const target = join13(dir, `${Date.now()}-${rand6()}.json`); if (existsSync8(target)) continue; const tmp = `${target}.tmp-${process.pid}`; try { @@ -2588,7 +3411,7 @@ function stampOf(dir, name) { if (Number.isFinite(n)) return n; } try { - return statSync7(join12(dir, name)).mtimeMs; + return statSync8(join13(dir, name)).mtimeMs; } catch { return 0; } @@ -2598,7 +3421,7 @@ function spoolStats(cfg, runId) { const dir = spoolDir(cfg, runId); let entries; try { - entries = readdirSync4(dir, { withFileTypes: true }); + entries = readdirSync5(dir, { withFileTypes: true }); } catch { return { count: 0, oldestMs: 0 }; } @@ -2616,17 +3439,17 @@ function spoolStats(cfg, runId) { return { count: 0, oldestMs: 0 }; } } -var ALPHABET; +var ALPHABET2; var init_spool = __esm({ "../claude-code/lib/spool.mjs"() { init_state(); - ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + ALPHABET2 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; } }); // ../claude-code/hooks/src/capture.mjs var capture_exports = {}; -import { join as join13 } from "node:path"; +import { join as join14 } from "node:path"; function pickMode(argv) { const args = Array.isArray(argv) ? argv : []; if (args.includes("--failure")) return "failure"; @@ -2637,46 +3460,78 @@ function pickMode(argv) { return "tool"; } function capture(rawPayload, cfg, mode) { - const payload = isObject8(rawPayload) ? rawPayload : {}; - if (cfg && cfg.capture === false) return; - if ((mode === "tool" || mode === "permission") && SKIP_TOOLS.has(str5(payload.tool_name).trim())) return; + const payload = isObject9(rawPayload) ? rawPayload : {}; + if (cfg && cfg.capture === false) return null; + if ((mode === "tool" || mode === "permission") && SKIP_TOOLS.has(str7(payload.tool_name).trim())) return null; + if (mode === "tool") attempt(() => noteOwnTool(cfg, payload)); if (mode === "tool" || mode === "permission") { - if (attempt(() => isSelfReference(payload.tool_name, payload.tool_input, cfg), false)) return; + if (attempt(() => isSelfReference(payload.tool_name, payload.tool_input, cfg), false)) return null; } if (mode === "tool" || mode === "failure" || mode === "permission") { - if (attempt(() => hasDeniedSubject(payload, cfg), false)) return; + if (attempt(() => hasDeniedSubject(payload, cfg), false)) return null; } const runId = attempt(() => deriveRunId(cfg, payload), ""); - if (!runId) return; + if (!runId) return null; + const pending = mode === "stop" || mode === "stop-failure" ? attempt(() => { + const t = readTurn(cfg, runId, turnKey(payload)); + return reviewPending(t) ? t : null; + }, null) : null; + const followUp = mode === "stop" && payload.stop_hook_active === true && !!pending; + const afterReview = followUp ? stripReviewLine(str7(payload.last_assistant_message) || str7(payload.message)) : ""; + const answered = followUp && !!afterReview && Number(pending?.queued_at) > Number(pending?.review_requested_at); const item2 = attempt( () => { - if (mode === "stop-failure") return null; + if (mode === "stop-failure" || followUp && !answered) return null; if (mode === "permission") return buildPermissionItem(payload, cfg); + if (answered) { + return buildTurnItem({ ...payload, last_assistant_message: afterReview }, cfg, runId, mode, "queued"); + } return mode === "stop" || mode === "subagent" ? buildTurnItem(payload, cfg, runId, mode) : buildToolItem(payload, cfg, mode, runId); }, null ); if (item2) attempt(() => appendItem(cfg, runId, item2)); if (mode === "stop") { - attempt(() => closeTurn(cfg, runId, payload)); + const closed = attempt(() => closeTurn(cfg, runId, payload, { followUp, answered }), null); + const summary = closed ? attempt(() => foldScorecard(closed.rows, closed.promptId), null) : null; + const review = attempt(() => reviewFor(cfg, payload, closed, summary), null); + if (review) { + attempt(() => markReview(cfg, runId, payload, closed, review.ids)); + if (attempt(() => drainTriggerFired(cfg, runId), false)) attempt(() => fireDrain(cfg, runId, payload, [])); + return { decision: "block", reason: review.reason }; + } attempt(() => fireDrain(cfg, runId, payload, outcomeArgs(payload))); - return; + return attempt(() => cardFor(cfg, summary), null); + } + if (mode === "stop-failure" && pending) { + attempt(() => closeTurn(cfg, runId, payload, { followUp: true, reviewError: apiErrorOf(payload) })); + attempt(() => fireDrain(cfg, runId, payload, outcomeArgs(payload))); + return null; } if (mode === "stop-failure") { - attempt(() => closeTurn(cfg, runId, payload, apiErrorOf(payload))); + attempt(() => closeTurn(cfg, runId, payload, { apiError: apiErrorOf(payload) })); } if (attempt(() => drainTriggerFired(cfg, runId), false)) { attempt(() => fireDrain(cfg, runId, payload, [])); } + return null; } function apiErrorOf(payload) { - const raw = str5(payload.error).trim(); + const raw = str7(payload.error).trim(); return raw ? clamp(raw, MAX_API_ERROR_CHARS) : API_ERROR_UNKNOWN; } function buildToolItem(payload, cfg, mode, runId) { const recorded = mode === "tool" && host() === "codex" ? attempt(() => toolCallRecord(payload.transcript_path, payload.tool_use_id), null) : null; const failed = mode === "failure" || !!recorded?.failed; - const toolName = clamp(str5(payload.tool_name) || "Tool", 128); + const toolName = clamp(str7(payload.tool_name) || "Tool", 128); + if (!str7(payload.agent_id) && !ownToolName(payload.tool_name)) { + attempt(() => appendScoreRow(cfg, payload.session_id, { + kind: "tool", + prompt_id: turnKey(payload), + failed, + intent: toolIntent(payload.tool_name, payload.tool_input) + })); + } const changes = failed ? [] : attempt(() => fileChanges(payload.tool_name, payload.tool_input, payload.tool_response), []); if (changes.length) attempt(() => recordFileChanges(cfg, runId, changes)); const cls = attempt( @@ -2698,7 +3553,7 @@ function buildToolItem(payload, cfg, mode, runId) { return item({ cfg, payload, - // §5.4: "item_id is stable per tool call so a retried drain deduplicates." Derived from + // item_id is stable per tool call, so a retried drain deduplicates. Derived from // `tool_use_id` and nothing else — a timestamp in here would make every retry a new // entry, which is the exact failure the dedup exists to prevent. id: `cc-${idPart(payload.tool_use_id) || fallbackId(payload, text)}`, @@ -2707,9 +3562,9 @@ function buildToolItem(payload, cfg, mode, runId) { importance: cls.importance, metadata: { tool: toolName, - tool_use_id: str5(payload.tool_use_id), - hook_event: str5(payload.hook_event_name) || (failed ? "PostToolUseFailure" : "PostToolUse"), - session_id: str5(payload.session_id), + tool_use_id: str7(payload.tool_use_id), + hook_event: str7(payload.hook_event_name) || (failed ? "PostToolUseFailure" : "PostToolUse"), + session_id: str7(payload.session_id), prompt_id: turnKey(payload), // The host names it `duration_ms`; `execution_time_ms` is the older payload name and // stays as a fallback. The metadata key keeps the old spelling because it is already @@ -2733,13 +3588,13 @@ function buildToolItem(payload, cfg, mode, runId) { // files" on every `Read` in the store. ...changes.length ? { files: changes } : {}, truncated: !!(params.truncated || tail.truncated), - redactions: num2(scrubbed.redactions) + num2(params.redactions) + num2(tail.redactions), - ...isObject8(cls.metadata) ? cls.metadata : {} + redactions: num3(scrubbed.redactions) + num3(params.redactions) + num3(tail.redactions), + ...isObject9(cls.metadata) ? cls.metadata : {} } }); } function buildPermissionItem(payload, cfg) { - const toolName = clamp(str5(payload.tool_name) || "Tool", 128); + const toolName = clamp(str7(payload.tool_name) || "Tool", 128); const scrubbed = attempt(() => redactParams(payload.tool_input, cfg), { params: null, redactions: 0 }); const params = attempt( () => redactText(renderParams(scrubbed.params), cfg, "param"), @@ -2755,39 +3610,39 @@ function buildPermissionItem(payload, cfg) { importance: "medium", metadata: { tool: toolName, - hook_event: str5(payload.hook_event_name) || "PermissionRequest", - session_id: str5(payload.session_id), + hook_event: str7(payload.hook_event_name) || "PermissionRequest", + session_id: str7(payload.session_id), prompt_id: turnKey(payload), permission_requested: true, // Deliberately not an `outcome`: this event fires *before* the human answers, and the // plugin never learns what they said. Stamping `ok` here would be a claim about a // decision nobody recorded. truncated: !!params.truncated, - redactions: num2(scrubbed.redactions) + num2(params.redactions) + redactions: num3(scrubbed.redactions) + num3(params.redactions) } }); } -function buildTurnItem(payload, cfg, runId, mode) { +function buildTurnItem(payload, cfg, runId, mode, suffix = "") { const event = mode === "subagent" ? "SubagentStop" : "Stop"; const cls = attempt( () => classifyTurn("", "", { event, - agent_id: str5(payload.agent_id), - agent_type: str5(payload.agent_type) + agent_id: str7(payload.agent_id), + agent_type: str7(payload.agent_type) }), { intent: "task_result", importance: "medium", contentType: "text", agentId: "", agentType: "" } ); const turn = attempt(() => readTurn(cfg, runId, turnKey(payload)), null); const own = mode === "subagent" ? attempt(() => firstUserText(payload.agent_transcript_path), "") : ""; - const staged = own || str5(turn?.prompt) || str5(payload.prompt); - const answer = str5(payload.last_assistant_message) || str5(payload.message); + const staged = own || str7(turn?.prompt) || str7(payload.prompt); + const answer = str7(payload.last_assistant_message) || str7(payload.message); if (!staged && !answer) return null; const q = attempt(() => redactText(staged, cfg, "output"), { text: "", redactions: 0, truncated: false }); const a = attempt(() => redactText(answer, cfg, "output"), { text: "", redactions: 0, truncated: false }); const text = `Q: ${q.text} A: ${a.text}`; - const subAgent = str5(cls.agentId); + const subAgent = str7(cls.agentId); const handoff = mode === "subagent" ? { from_agent_id: attempt(() => deriveAgentId(payload), ""), to_agent_id: attempt(() => deriveAgentId({}), ""), @@ -2798,25 +3653,25 @@ A: ${a.text}`; return item({ cfg, payload, - id: mode === "subagent" ? `cc-sub-${idPart(payload.agent_id) || "anon"}-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}` : `cc-stop-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}`, + id: mode === "subagent" ? `cc-sub-${idPart(payload.agent_id) || "anon"}-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}` : `cc-stop-${idPart(turnKey(payload)) || idPart(payload.session_id) || "turn"}${suffix ? `-${suffix}` : ""}`, text, intent: cls.intent, importance: cls.importance, metadata: { - hook_event: str5(payload.hook_event_name) || event, - session_id: str5(payload.session_id), + hook_event: str7(payload.hook_event_name) || event, + session_id: str7(payload.session_id), prompt_id: turnKey(payload), // Payload first, then the ordinal `stage-prompt` staged — Codex sends no `turn_number` // on any event, so without the second source every item here recorded 0. turn_number: attempt(() => turnNumber(cfg, runId, payload), 0), - ...subAgent ? { agent_id: subAgent, agent_type: str5(cls.agentType) } : {}, + ...subAgent ? { agent_id: subAgent, agent_type: str7(cls.agentType) } : {}, ...subAgent ? { mubit_agent_id: attempt(() => deriveAgentId(payload), "") } : {}, ...handoff, // The path itself, so a later reader can rejoin this subagent to its own rollout — // which is what `persistSubRun` names as the next step on real per-subagent isolation. - ...str5(payload.agent_transcript_path) ? { agent_transcript_path: str5(payload.agent_transcript_path) } : {}, + ...str7(payload.agent_transcript_path) ? { agent_transcript_path: str7(payload.agent_transcript_path) } : {}, truncated: !!(q.truncated || a.truncated), - redactions: num2(q.redactions) + num2(a.redactions) + redactions: num3(q.redactions) + num3(a.redactions) } }); } @@ -2830,8 +3685,7 @@ function item(o) { intent: intentOr(o.intent), importance: importanceOr(o.importance), source: "agent", - // Unix SECONDS, as in the §5.4 example — `occurrence_time` is an int64 of seconds - // (control.proto) and handing it milliseconds dates every memory to the year 57000. + // Unix SECONDS — `occurrence_time` is an int64 of seconds, and handing it milliseconds dates every memory to the year 57000. occurrence_time: Math.floor(Date.now() / 1e3), // The tags are derived from a directory too, and they ride on every ingested item. A // run id that follows a mid-session `cd` while `repo:`/`branch:` stay on the launch repo @@ -2851,18 +3705,18 @@ function item(o) { // empty string would read as "the host said the model is blank". metadata_json: safeJson(withActor(withModel(o.metadata, o.payload), actor)) }; - const userId = str5(cfg.userId); + const userId = str7(cfg.userId); if (userId) out.user_id = userId; return out; } function withModel(metadata, payload) { - const base = isObject8(metadata) ? metadata : {}; + const base = isObject9(metadata) ? metadata : {}; if ("model" in base) return base; - const model = clamp(str5(payload?.model), 128); + const model = clamp(str7(payload?.model), 128); return model ? { ...base, model } : base; } function withActor(metadata, actor) { - const base = isObject8(metadata) ? metadata : {}; + const base = isObject9(metadata) ? metadata : {}; return actor ? { ...base, actor } : base; } function renderParams(params) { @@ -2913,7 +3767,7 @@ function outputText(v, depth = 0) { return v.map((x) => outputText(x, depth + 1)).filter(Boolean).join("\n"); } if (typeof v.text === "string") return v.text; - if (isObject8(v.file) && typeof v.file.content === "string") return v.file.content; + if (isObject9(v.file) && typeof v.file.content === "string") return v.file.content; if (typeof v.content === "string") return v.content; if (Array.isArray(v.content)) return outputText(v.content, depth + 1); if (typeof v.output === "string") return v.output; @@ -2926,31 +3780,57 @@ function outputText(v, depth = 0) { } } function errorText(payload) { - const direct = str5(payload.error) || str5(payload.tool_error) || str5(payload.error_message); + const direct = str7(payload.error) || str7(payload.tool_error) || str7(payload.error_message); if (direct) return direct; return outputText(payload.error ?? payload.tool_response ?? payload.tool_output); } function turnPath(cfg, runId, promptId) { const id = idPart(promptId); if (!id) return ""; - return join13(resolveDataDir(cfg), "runs", idPart(runId), "turns", `${id}.json`); + return join14(resolveDataDir(cfg), "runs", idPart(runId), "turns", `${id}.json`); } function readTurn(cfg, runId, promptId) { const p = turnPath(cfg, runId, promptId); if (!p) return null; const v = readJson(p, null); - return isObject8(v) ? v : null; -} -function closeTurn(cfg, runId, payload, apiError = "") { - const p = turnPath(cfg, runId, turnKey(payload)); - if (!p) return; + return isObject9(v) ? v : null; +} +function closeTurn(cfg, runId, payload, opts = {}) { + const apiError = str7(opts.apiError); + const followUp = opts.followUp === true; + const answered = followUp && opts.answered === true; + const reviewError = str7(opts.reviewError); + const promptId = turnKey(payload); + const p = turnPath(cfg, runId, promptId); + if (!p) return null; const prev = readJson(p, null); - const base = isObject8(prev) ? prev : { - prompt_id: turnKey(payload), - session_id: str5(payload.session_id), + const base = isObject9(prev) ? prev : { + prompt_id: promptId, + session_id: str7(payload.session_id), started_at: Date.now() }; - const evidence = apiError ? null : attempt(() => usedEvidence(base, payload), null); + const sessionId = str7(payload.session_id); + const logged = cfg?.capture !== false && !!sessionId; + const rows = logged ? attempt(() => readScoreRows(cfg, sessionId), []) : []; + const standing = attempt(() => standingFor(rows, promptId), []); + const prevEvidence = isObject9(base.used_evidence) ? base.used_evidence : null; + const raw = str7(payload.last_assistant_message) || str7(payload.message); + const reply = answered ? stripReviewLine(raw) : raw; + const measured = answered ? { ...payload, last_assistant_message: reply } : payload; + let evidence = null; + if (!apiError) { + if (followUp && !answered) { + evidence = prevEvidence; + } else { + const v1 = attempt(() => usedEvidence(base, measured), null); + const entries = attempt(() => entryEvidence(base, standing, measured), null); + const fresh = v1 || entries ? { ...v1 ?? {}, ...entries ? { entry_method: ENTRY_SIGNAL_METHOD, entries } : {} } : null; + evidence = answered ? mergeEvidence(prevEvidence, fresh) : fresh; + } + } + const explicit = attempt(() => explicitFor(rows, promptId, base), { ids: [], byRef: {} }); + const toolFailed = !apiError && attempt(() => lastActingToolFailed(rows, promptId), false); + const endedWithQuestion = followUp && !answered ? base.ended_with_question === true : /\?\s*$/.test(reply.trim()); const closed = { ...base, // Absent when nothing was staged to look for. An absent key means "unmeasured" and the @@ -2959,17 +3839,184 @@ function closeTurn(cfg, runId, payload, apiError = "") { // existed as an injection the model ignored. ...evidence ? { used_evidence: evidence } : {}, ...apiError ? { [API_ERROR_KEY2]: apiError } : {}, + ...explicit.ids.length ? { explicit_ids: explicit.ids, explicit: explicit.byRef } : {}, + ...followUp ? { review_closed_at: Date.now() } : {}, + ...reviewError ? { review_error: reviewError } : {}, + ended_with_question: endedWithQuestion, ended_at: Date.now(), outcome_pending: true }; + if (toolFailed) { + closed.outcome = "failure"; + closed.failure_reason = "tool_failure"; + } else if (str7(base.failure_reason) === "tool_failure") { + delete closed.outcome; + delete closed.failure_reason; + } writeJsonAtomic(p, closed); attempt(() => appendLedger(resolveDataDir(cfg), runId, turnLedgerRow(closed, runId, Date.now()))); + if (logged) { + const entries = isObject9(closed.used_evidence?.entries) ? closed.used_evidence.entries : {}; + const explicitSet = new Set(explicit.ids); + const row = { + kind: "turn", + prompt_id: promptId, + run_id: runId, + lessons: lessonsOfTurn(base, standing, entries), + used_refs: Object.keys(entries).filter((r) => entries[r]?.used === true && !explicitSet.has(r)), + ...apiError ? { api_error: apiError } : {}, + ended_with_question: endedWithQuestion + }; + if (attempt(() => appendScoreRow(cfg, sessionId, row), false)) rows.push({ v: 1, at: Date.now(), ...row }); + } + return { turn: closed, rows, promptId }; +} +function mergeEvidence(prev, next) { + if (!isObject9(prev)) return next; + if (!isObject9(next)) return prev; + const rank = (e) => e?.used === true ? 2 : e?.used === false ? 1 : 0; + const base = rank(next) > rank(prev) ? next : prev; + const a = isObject9(prev.entries) ? prev.entries : {}; + const b = isObject9(next.entries) ? next.entries : {}; + const entries = {}; + for (const ref of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) { + entries[ref] = rank(b[ref]) > rank(a[ref]) ? b[ref] : a[ref]; + } + const { entries: _drop, ...v1 } = base; + return Object.keys(entries).length ? { ...v1, entry_method: ENTRY_SIGNAL_METHOD, entries } : v1; +} +function standingFor(rows, promptId) { + let start = -1; + for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === "start") { + start = i; + break; + } + if (start < 0 || !isObject9(rows[start].lessons)) return []; + const first = rows.slice(start + 1).find((r) => r.kind === "prompt" && r.slash !== true); + if (!first || first.prompt_id !== promptId) return []; + return Object.entries(rows[start].lessons).filter(([ref, v]) => ref && isObject9(v)).map(([ref, v]) => ({ ref, terms: Array.isArray(v.terms) ? v.terms : [] })); +} +function entryEvidence(turn, standing, payload) { + const shown = Array.isArray(turn.shown) ? turn.shown.filter((e) => isObject9(e) && str7(e.ref)) : []; + const refs = new Set(shown.map((e) => e.ref)); + const list2 = [ + ...shown.map((e) => ({ ref: e.ref, terms: Array.isArray(e.terms) ? e.terms : [] })), + ...standing.filter((e) => !refs.has(e.ref)) + ]; + if (!list2.length) return null; + const reply = str7(payload.last_assistant_message) || str7(payload.message); + return evaluateUse(list2, reply, { exclude: termSet(str7(turn.prompt)) }); +} +function lessonsOfTurn(turn, standing, entries) { + const refs = [ + ...(Array.isArray(turn.shown) ? turn.shown : []).filter((e) => isObject9(e) && str7(e.ref) && str7(e.type) === "lesson").map((e) => e.ref), + ...standing.map((e) => e.ref) + ]; + const out = {}; + for (const ref of refs) { + const e = isObject9(entries[ref]) ? entries[ref] : null; + out[ref] = { + used: e && typeof e.used === "boolean" ? e.used : null, + matched: e && Array.isArray(e.matched) ? e.matched.slice(0, MAX_EVIDENCE_TERMS) : [] + }; + } + return out; +} +function explicitFor(rows, promptId, turn) { + const byRef = isObject9(turn.explicit) ? { ...turn.explicit } : {}; + const ids = Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v) : []; + for (const r of rows) { + if (r.kind !== "explicit" || r.prompt_id !== promptId || !Array.isArray(r.ids)) continue; + for (const ref of r.ids) { + if (typeof ref !== "string" || !ref) continue; + if (!ids.includes(ref)) ids.push(ref); + byRef[ref] = str7(r.outcome); + } + } + return { ids, byRef }; +} +function lastActingToolFailed(rows, promptId) { + const acting = rows.filter((r) => r.kind === "tool" && r.prompt_id === promptId && !READ_ONLY_INTENTS2.has(str7(r.intent))); + return acting.length > 0 && acting[acting.length - 1].failed === true; +} +function reviewPending(turn) { + return !!turn && Number(turn.review_requested_at) > 0 && !(Number(turn.review_closed_at) > 0); +} +function reviewFor(cfg, payload, closed, summary) { + if (!closed || !summary) return null; + const candidates = reviewCandidates(summary.thisTurn.lessons); + const ok = shouldReview({ + outcomeReview: str7(cfg?.outcomeReview), + outcomeMode: str7(cfg?.outcomeMode), + stopHookActive: payload.stop_hook_active === true, + alreadyRequested: Number(closed.turn.review_requested_at) > 0, + apiError: str7(closed.turn[API_ERROR_KEY2]), + isSubagent: !!str7(payload.agent_id), + candidates + }); + if (!ok) return null; + const reason = reviewReason(candidates); + return reason ? { ids: candidates.map((c) => c.ref), reason } : null; +} +function markReview(cfg, runId, payload, closed, ids) { + const p = turnPath(cfg, runId, closed.promptId); + if (p) writeJsonAtomic(p, { ...closed.turn, review_requested_at: Date.now(), review_ids: ids }); + appendScoreRow(cfg, payload.session_id, { kind: "review", prompt_id: closed.promptId, ids }); +} +function cardFor(cfg, summary) { + const mode = str7(cfg?.sessionScore) || "full"; + if (!summary || mode === "off") return null; + const text = renderScorecard(summary, mode); + return text ? { systemMessage: text, suppressOutput: true } : null; +} +function ownToolName(name) { + const n = str7(name); + for (const prefix of OWN_MCP_PREFIXES3) if (n.startsWith(prefix)) return n.slice(prefix.length); + return ""; +} +function noteOwnTool(cfg, payload) { + const tool = ownToolName(payload.tool_name); + if (tool !== "mubit_outcome" && tool !== "mubit_learned") return; + if (resultIsError(payload.tool_response)) return; + const sessionId = str7(payload.session_id); + if (!sessionId) return; + const promptId = turnKey(payload); + if (tool === "mubit_learned") { + appendScoreRow(cfg, sessionId, { kind: "learned", prompt_id: promptId }); + return; + } + if (str7(payload.agent_id)) return; + const input = isObject9(payload.tool_input) ? payload.tool_input : {}; + const outcome = str7(input.outcome).trim().toLowerCase(); + if (!VERDICTS2.has(outcome)) return; + const primary = str7(input.reference_id).trim(); + const raw = [ + ...primary && primary !== "global" ? [primary] : [], + ...Array.isArray(input.entry_ids) ? input.entry_ids : [] + ]; + const resolved = resolveHandles(raw, knownRefsFromRows(readScoreRows(cfg, sessionId))); + const unresolved = new Set(resolved.unresolved); + const ids = [...new Set(resolved.ids.filter((id) => id && id !== "global" && !unresolved.has(id)))]; + if (!ids.length) return; + appendScoreRow(cfg, sessionId, { kind: "explicit", prompt_id: promptId, ids, outcome }); + const p = turnPath(cfg, deriveRunId(cfg, payload), promptId); + const prev = p ? readJson(p, null) : null; + if (!isObject9(prev)) return; + const explicitIds = Array.isArray(prev.explicit_ids) ? prev.explicit_ids.filter((v) => typeof v === "string") : []; + writeJsonAtomic(p, { + ...prev, + explicit_ids: [.../* @__PURE__ */ new Set([...explicitIds, ...ids])], + explicit: { ...isObject9(prev.explicit) ? prev.explicit : {}, ...Object.fromEntries(ids.map((id) => [id, outcome])) } + }); +} +function resultIsError(response) { + return isObject9(response) && response.isError === true; } function usedEvidence(turn, payload) { - const recall = isObject8(turn.recall) ? turn.recall : null; + const recall = isObject9(turn.recall) ? turn.recall : null; if (!recall || !Array.isArray(recall.terms)) return null; const terms = recall.terms.filter((t) => typeof t === "string" && t.length >= TERM_MIN_CHARS && t.length <= TERM_MAX_CHARS).map((t) => t.toLowerCase()).slice(0, MAX_TERMS_READ); - const answer = str5(payload.last_assistant_message) || str5(payload.message); + const answer = str7(payload.last_assistant_message) || str7(payload.message); const out = { method: USED_SIGNAL_METHOD, at: Date.now(), @@ -2986,16 +4033,12 @@ function usedEvidence(turn, payload) { out.reason = "no_reply"; return out; } - const hits = matchTerms(terms, answer.slice(0, MAX_ANSWER_SCAN)); + const hits = matchTerms(terms, answer.slice(0, MAX_ANSWER_SCAN2)); out.matched = hits.length; out.terms = hits.slice(0, MAX_EVIDENCE_TERMS); out.used = hits.length > 0; return out; } -function matchTerms(terms, text) { - const hay = ` ${text.toLowerCase().replace(/[^a-z0-9_]+/g, " ")} `; - return terms.filter((t) => hay.includes(` ${t}`)); -} function drainTriggerFired(cfg, runId) { const stats = spoolStats(cfg, runId); if (!stats || stats.count <= 0) return false; @@ -3009,14 +4052,14 @@ function outcomeArgs(payload) { } function fireDrain(cfg, runId, payload, args) { const handoff = stashPayload(cfg, { - hook_event_name: str5(payload.hook_event_name), - session_id: str5(payload.session_id), + hook_event_name: str7(payload.hook_event_name), + session_id: str7(payload.session_id), prompt_id: turnKey(payload), - transcript_path: str5(payload.transcript_path), - cwd: str5(payload.cwd), - permission_mode: str5(payload.permission_mode), - agent_id: str5(payload.agent_id), - agent_type: str5(payload.agent_type), + transcript_path: str7(payload.transcript_path), + cwd: str7(payload.cwd), + permission_mode: str7(payload.permission_mode), + agent_id: str7(payload.agent_id), + agent_type: str7(payload.agent_type), // Resolved here rather than in the child: the child reads this stash, not the payload. turn_number: attempt(() => turnNumber(cfg, runId, payload), 0), run_id: runId @@ -3024,15 +4067,15 @@ function fireDrain(cfg, runId, payload, args) { spawnDetached(cfg, "drain", args, handoff); } function hasDeniedSubject(payload, cfg) { - const input = isObject8(payload.tool_input) ? payload.tool_input : {}; - const projectDir2 = str5(cfg?.projectDir); + const input = isObject9(payload.tool_input) ? payload.tool_input : {}; + const projectDir2 = str7(cfg?.projectDir); for (const key of PATH_KEYS3) { const v = input[key]; if (typeof v === "string" && v && isDeniedPath(v, cfg, projectDir2)) return true; } const edits = Array.isArray(input.edits) ? input.edits.slice(0, MAX_RENDER_ITEMS) : []; for (const e of edits) { - if (!isObject8(e)) continue; + if (!isObject9(e)) continue; for (const key of PATH_KEYS3) { const v = e[key]; if (typeof v === "string" && v && isDeniedPath(v, cfg, projectDir2)) return true; @@ -3053,13 +4096,13 @@ function attempt(fn, fallback = ( return fallback; } } -function isObject8(v) { +function isObject9(v) { return !!v && typeof v === "object" && !Array.isArray(v); } -function str5(v) { +function str7(v) { return typeof v === "string" ? v : ""; } -function num2(v) { +function num3(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } @@ -3080,7 +4123,7 @@ function idPart(v) { } function fallbackId(payload, text) { let h = 2166136261; - const seed = `${str5(payload.session_id)}|${turnKey(payload)}|${str5(payload.tool_name)}|${text}`; + const seed = `${str7(payload.session_id)}|${turnKey(payload)}|${str7(payload.tool_name)}|${text}`; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; @@ -3088,11 +4131,11 @@ function fallbackId(payload, text) { return `anon-${h.toString(16).padStart(8, "0")}`; } function intentOr(v) { - const s = str5(v).trim(); + const s = str7(v).trim(); return s && s !== "unclassified" ? s : "tool_output"; } function importanceOr(v) { - const s = str5(v).trim().toLowerCase(); + const s = str7(v).trim().toLowerCase(); return ["low", "medium", "high", "critical"].includes(s) ? s : "medium"; } function safeJson(v) { @@ -3103,7 +4146,7 @@ function safeJson(v) { return "{}"; } } -var BUDGET_MS, PATH_KEYS3, SKIP_TOOLS, MAX_RENDER_DEPTH, MAX_RENDER_ITEMS, MAX_VALUE_CHARS, MAX_ID_CHARS, USED_SIGNAL_METHOD, MAX_EVIDENCE_TERMS, MAX_ANSWER_SCAN, MAX_TERMS_READ, TERM_MIN_CHARS, TERM_MAX_CHARS, API_ERROR_KEY2, MAX_API_ERROR_CHARS, API_ERROR_UNKNOWN, MODE2; +var BUDGET_MS, PATH_KEYS3, SKIP_TOOLS, MAX_RENDER_DEPTH, MAX_RENDER_ITEMS, MAX_VALUE_CHARS, MAX_ID_CHARS, USED_SIGNAL_METHOD, ENTRY_SIGNAL_METHOD, OWN_MCP_PREFIXES3, VERDICTS2, READ_ONLY_INTENTS2, MAX_EVIDENCE_TERMS, MAX_ANSWER_SCAN2, MAX_TERMS_READ, TERM_MIN_CHARS, TERM_MAX_CHARS, API_ERROR_KEY2, MAX_API_ERROR_CHARS, API_ERROR_UNKNOWN, MODE2; var init_capture = __esm({ async "../claude-code/hooks/src/capture.mjs"() { init_actor(); @@ -3113,6 +4156,11 @@ var init_capture = __esm({ init_classify(); init_ledger(); init_filechange(); + init_handles(); + init_review(); + init_scorecard(); + init_scorecard_log(); + init_terms(); init_redact(); init_runid(); init_spool(); @@ -3143,8 +4191,12 @@ var init_capture = __esm({ MAX_VALUE_CHARS = 4096; MAX_ID_CHARS = 160; USED_SIGNAL_METHOD = "memory-term-echo/v1"; + ENTRY_SIGNAL_METHOD = "memory-term-echo/v2-entry"; + OWN_MCP_PREFIXES3 = ["mcp__plugin_mubit-memory_mubit__", "mcp__mubit__"]; + VERDICTS2 = /* @__PURE__ */ new Set(["success", "failure", "partial", "neutral"]); + READ_ONLY_INTENTS2 = /* @__PURE__ */ new Set(["read", "search"]); MAX_EVIDENCE_TERMS = 12; - MAX_ANSWER_SCAN = 64 * 1024; + MAX_ANSWER_SCAN2 = 64 * 1024; MAX_TERMS_READ = 64; TERM_MIN_CHARS = 4; TERM_MAX_CHARS = 24; @@ -3155,8 +4207,7 @@ var init_capture = __esm({ await runHook("capture", { budgetMs: BUDGET_MS, body: (payload, cfg) => { - capture(payload, cfg, MODE2); - return { suppressOutput: true }; + return capture(payload, cfg, MODE2) ?? { suppressOutput: true }; } }); } diff --git a/integrations/codex/hooks/dist/impl/checkpoint.mjs b/integrations/codex/hooks/dist/impl/checkpoint.mjs index c665704..f062f5a 100755 --- a/integrations/codex/hooks/dist/impl/checkpoint.mjs +++ b/integrations/codex/hooks/dist/impl/checkpoint.mjs @@ -470,6 +470,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -543,6 +553,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -688,7 +700,7 @@ var init_config = __esm({ ]; CACHE_FILE2 = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION2 = 3; + CACHE_VERSION2 = 4; MAX_ENV_TAGS = 8; MODE = "hosted"; LANG_FILES = [ @@ -1517,7 +1529,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1590,7 +1602,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1681,7 +1693,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1912,7 +1924,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2152,6 +2164,49 @@ var init_runid = __esm({ } }); +// ../claude-code/lib/codex-rollout.mjs +function isHookFeedback(text) { + const end = text.trimEnd().length; + let at = 0; + do { + HOOK_PROMPT_OPEN_RE.lastIndex = at; + if (!HOOK_PROMPT_OPEN_RE.test(text)) return false; + const close = text.indexOf(HOOK_PROMPT_CLOSE, HOOK_PROMPT_OPEN_RE.lastIndex); + if (close === -1) return false; + at = close + HOOK_PROMPT_CLOSE.length; + } while (at < end); + return true; +} +function isInjectedUserText(text, opts) { + if (typeof text !== "string") return false; + if (INJECTED_USER_RE.test(text) || isHookFeedback(text)) return true; + return !opts?.keepUserActions && USER_ACTION_RE.test(text); +} +function stripInjectedBlocks(content, opts) { + if (typeof content === "string") return isInjectedUserText(content, opts) ? "" : content; + if (!Array.isArray(content)) return content; + return content.filter((b) => !(b && typeof b === "object" && !Array.isArray(b) && isInjectedUserText(b.text, opts))); +} +var TAIL_BYTES, HEAD_BYTES, INJECTED_USER_RE, USER_ACTION_RE, HOOK_PROMPT_OPEN_RE, HOOK_PROMPT_CLOSE, NOT_FOUND; +var init_codex_rollout = __esm({ + "../claude-code/lib/codex-rollout.mjs"() { + init_transcript(); + TAIL_BYTES = 512 * 1024; + HEAD_BYTES = 256 * 1024; + INJECTED_USER_RE = /^\s*(?:<(?:environment_context|recommended_plugins|skill|image)\b|# AGENTS\.md instructions|# Files mentioned)/; + USER_ACTION_RE = /^\s*<(?:turn_aborted|user_shell_command)\b/; + HOOK_PROMPT_OPEN_RE = /\s*]*)?>/y; + HOOK_PROMPT_CLOSE = ""; + NOT_FOUND = Object.freeze({ + found: false, + failed: false, + exitCode: null, + durationMs: null, + status: "" + }); + } +}); + // ../claude-code/lib/transcript.mjs function parseLine(line) { const s = typeof line === "string" ? line.trim() : ""; @@ -2197,7 +2252,9 @@ function renderEntry(line, opts = {}) { const entry = parseLine(s); if (!entry) return s; const message = messageRecord(entry); - const body = messageText(message.content ?? entry.content ?? entry.text, opts); + let content = message.content ?? entry.content ?? entry.text; + if (message === entry.payload && str2(message.role) === "user") content = stripInjectedBlocks(content, { keepUserActions: true }); + const body = messageText(content, opts); if (!body.trim()) return ""; const role = str2(message.role) || str2(entry.role) || str2(entry.type) || "message"; return `${role}: ${body}`; @@ -2211,6 +2268,7 @@ function str2(v) { var CHUNK_BYTES, MAX_LINE_BYTES, TEXT_BLOCKS, MAX_CONTENT_DEPTH; var init_transcript = __esm({ "../claude-code/lib/transcript.mjs"() { + init_codex_rollout(); CHUNK_BYTES = 256 * 1024; MAX_LINE_BYTES = 2 * 1024 * 1024; TEXT_BLOCKS = /* @__PURE__ */ new Set(["text", "input_text", "output_text"]); @@ -2527,7 +2585,7 @@ ${tail}`, cfg, "output"), ); const actor = attempt(() => readActor(cfg), ""); appendItem(cfg, runId, { - // §1.3: `item_id` and `content_type` are REQUIRED — a missing one is a 422 for the whole + // `item_id` and `content_type` are REQUIRED — a missing one is a 422 for the whole // batch. Derived from (session, counter) and never from a clock, so a retried drain // deduplicates instead of writing a second anchor for one compaction. item_id: clamp(`cc-precompact-${idPart(payload.session_id) || idPart(runId) || "anon"}-${label.slice(LABEL_PREFIX.length)}`, MAX_ID_CHARS), @@ -2536,7 +2594,7 @@ ${tail}`, cfg, "output"), intent: str3(cls.intent) || "checkpoint", importance: importanceOr(cls.importance), source: "agent", - // Unix SECONDS (`control.proto`); milliseconds here dates every memory to the year 57000. + // Unix SECONDS; milliseconds here dates every memory to the year 57000. occurrence_time: Math.floor(Date.now() / 1e3), // From the payload's directory, not the launch one: after a mid-session `cd` the run id // follows the new repo, and `repo:`/`branch:` have to follow it or the item lands in the diff --git a/integrations/codex/hooks/dist/impl/drain.mjs b/integrations/codex/hooks/dist/impl/drain.mjs index 3e43f01..e647b9c 100755 --- a/integrations/codex/hooks/dist/impl/drain.mjs +++ b/integrations/codex/hooks/dist/impl/drain.mjs @@ -153,6 +153,9 @@ function pruneStale(cfg = {}) { for (const name of jsonFiles(join2(root, "import"))) { expire(join2(root, "import", name), 30 * DAY); } + for (const e of dirEntries(join2(root, "scorecard"))) { + if (e.isFile() && e.name.endsWith(".jsonl")) expire(join2(root, "scorecard", e.name), 7 * DAY); + } for (const e of dirEntries(join2(root, "tmp"))) { if (e.isFile()) expire(join2(root, "tmp", e.name), 1 * HOUR); } @@ -768,6 +771,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -841,6 +854,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -986,7 +1001,7 @@ var init_config = __esm({ ]; CACHE_FILE2 = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION2 = 3; + CACHE_VERSION2 = 4; MODE = "hosted"; } }); @@ -1002,15 +1017,50 @@ function implicitOutcomesEnabled(cfg) { function decideOutcome(turn) { if (!isObject2(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str2(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject2(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str2(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str2(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -1020,8 +1070,36 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) + }; +} +function decideCorrection(turn) { + if (!isObject2(turn)) return { post: false, reason: "not_a_turn" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; + if (str2(turn.outcome).toLowerCase() === "failure") return { post: false, reason: "already_failed" }; + const ev = isObject2(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (!entries) return { post: false, reason: "nothing_used" }; + const explicit = new Set(explicitIdsOf(turn)); + const ids = Object.keys(entries).filter((r) => entries[r].used === true && !explicit.has(r)); + if (ids.length === 0) return { post: false, reason: "nothing_used" }; + return { + post: true, + outcome: OUTCOME_FAILURE, + signal: SIGNAL_FAILURE, + entryIds: ids, + rationale: `The user's next prompt corrected this Claude Code turn; the reply had used ${ids.length} ${ids.length === 1 ? "memory" : "memories"} (memory-term-echo/v2-entry).` + }; +} +function correctionIdempotencyKey(runId, promptId) { + return `cc-correction-${str2(runId)}-${str2(promptId)}`; +} +function correctionRequest(o) { + return { + ...outcomeRequest(o), + idempotency_key: correctionIdempotencyKey(o.runId, o.promptId) }; } function outcomeIdempotencyKey(runId, promptId) { @@ -1040,17 +1118,40 @@ function outcomeRequest(o) { idempotency_key: outcomeIdempotencyKey(o.runId, o.promptId) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str2(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + } + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str2(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject2(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject2(v)) out[ref] = /** @type {any} */ + v; + } + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject2(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -1457,7 +1558,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1527,7 +1628,7 @@ async function postOutcome(cfg, req, opts = {}) { req, "reference_id", "postOutcome", - 'pass "global" for run-level attribution and put the real ids in entry_ids[] (\xA71.3)' + 'pass "global" for run-level attribution and put the real ids in entry_ids[]' ) ); if (bad) return refuse(cfg, started, bad, { route: ROUTES.outcome }); @@ -1547,7 +1648,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1638,7 +1739,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireItems(req) { const items = req && typeof req === "object" ? req.items : void 0; @@ -1799,7 +1900,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -1878,10 +1979,23 @@ var init_markers = __esm({ } }); +// ../claude-code/lib/handles.mjs +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + // ../claude-code/lib/assemble.mjs var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS; var init_assemble = __esm({ "../claude-code/lib/assemble.mjs"() { + init_handles(); SECTION_KEYS = Object.freeze([ "mental_models", "active_rules", @@ -2241,7 +2355,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2720,6 +2834,7 @@ async function main() { const outcomeArg = flagValue(argv, "--with-outcome"); const wantsOutcome = argv.includes("--with-outcome"); const pinnedRun = flagValue(argv, "--run"); + const correctArg = str3(flagValue(argv, "--correct")); const payload = await readPayload(payloadPath); const cfg = loadConfig(process.env); cfgRef = cfg; @@ -2740,7 +2855,8 @@ async function main() { } const agentId = deriveAgentId(payload); const promptId = str3(outcomeArg) || turnKey(payload); - const lock = await acquireConfirmed(cfg, runId, wantsOutcome, started); + const waitMs = correctArg ? CORRECTION_LOCK_WAIT_MS : wantsOutcome ? OUTCOME_LOCK_WAIT_MS : 0; + const lock = await acquireConfirmed(cfg, runId, waitMs, started); if (!lock) { log(cfg, "debug", "drain: another drainer holds the lock; standing down", { run_id: runId }); return; @@ -2757,6 +2873,7 @@ async function main() { try { const drained = await drainSpool(cfg, runId, agentId, promptId, started); await flushOutcome(cfg, runId, agentId, promptId, wantsOutcome); + if (correctArg && !breakerOpen(cfg)) await sendCorrection(cfg, runId, agentId, correctArg); log(cfg, "info", `drain: ${drained.sent} item(s) in ${drained.batches} batch(es)`, { run_id: runId, rejected: drained.rejected, @@ -2829,7 +2946,7 @@ async function drainSpool(cfg, runId, agentId, promptId, started) { } return { sent, batches, rejected }; } -async function acquireConfirmed(cfg, runId, wantsOutcome, started) { +async function acquireConfirmed(cfg, runId, waitMs, started) { for (; ; ) { const lock = acquireDrainLock(cfg, runId); if (lock) { @@ -2837,7 +2954,7 @@ async function acquireConfirmed(cfg, runId, wantsOutcome, started) { if (await stillOurs(lock)) return lock; heldLock = null; } - if (!wantsOutcome || Date.now() - started >= OUTCOME_LOCK_WAIT_MS) return null; + if (Date.now() - started >= waitMs) return null; await sleep(LOCK_POLL_MS); } } @@ -2983,6 +3100,36 @@ async function sendOutcome(cfg, runId, agentId, promptId) { log(cfg, "warn", `drain: outcome skipped \u2014 ${messageOf3(err)}`, { run_id: runId }); } } +async function sendCorrection(cfg, runId, agentId, promptId) { + try { + if (!implicitOutcomesEnabled(cfg)) return; + const p = join13(runDir(cfg, runId), "turns", `${safeSegment(promptId)}.json`); + const turn = readJson(p, null); + const decision = decideCorrection(turn); + if (!decision.post) { + log(cfg, "debug", `drain: no correction to post (${decision.reason})`, { run_id: runId, prompt_id: promptId }); + return; + } + const res = await postOutcome( + cfg, + correctionRequest({ runId, agentId, promptId, decision }), + { timeoutMs: numOr2(cfg.timeoutMs, 4e3) } + ); + if (!res.ok) { + log(cfg, "warn", `drain: correction post failed (${res.state})`, { run_id: runId, prompt_id: promptId }); + return; + } + const fresh2 = readJson(p, turn); + writeJsonAtomic(p, { ...fresh2 && typeof fresh2 === "object" ? fresh2 : turn, correction_sent_at: Date.now() }); + appendLedger( + resolveDataDir(cfg), + runId, + { ...outcomeLedgerRow(runId, promptId, decision, 1), correction: true } + ); + } catch (err) { + log(cfg, "warn", `drain: correction skipped \u2014 ${messageOf3(err)}`, { run_id: runId }); + } +} function outcomeLedgerRow(runId, promptId, decision, attempts) { return { v: 1, @@ -3091,7 +3238,7 @@ function messageOf3(err) { return "unknown error"; } } -var BUDGET_MS, HARD_STOP_MS, JOBS_KEEP, LOCK_CONFIRM_MS, OUTCOME_LOCK_WAIT_MS, LOCK_POLL_MS, DEFAULT_BATCH, RETRYABLE_4XX, cfgRef, heldLock, payloadFile; +var BUDGET_MS, HARD_STOP_MS, JOBS_KEEP, LOCK_CONFIRM_MS, OUTCOME_LOCK_WAIT_MS, CORRECTION_LOCK_WAIT_MS, LOCK_POLL_MS, DEFAULT_BATCH, RETRYABLE_4XX, cfgRef, heldLock, payloadFile; var init_drain = __esm({ async "../claude-code/hooks/src/drain.mjs"() { init_actor(); @@ -3111,6 +3258,7 @@ var init_drain = __esm({ JOBS_KEEP = 20; LOCK_CONFIRM_MS = 20; OUTCOME_LOCK_WAIT_MS = 2e3; + CORRECTION_LOCK_WAIT_MS = HARD_STOP_MS + 3e3; LOCK_POLL_MS = 25; DEFAULT_BATCH = 32; RETRYABLE_4XX = /* @__PURE__ */ new Set([401, 403, 404, 408, 429]); diff --git a/integrations/codex/hooks/dist/impl/pre-tool.mjs b/integrations/codex/hooks/dist/impl/pre-tool.mjs index 2cb8777..b23626b 100755 --- a/integrations/codex/hooks/dist/impl/pre-tool.mjs +++ b/integrations/codex/hooks/dist/impl/pre-tool.mjs @@ -292,6 +292,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -365,6 +375,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -510,7 +522,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -1228,7 +1240,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; diff --git a/integrations/codex/hooks/dist/impl/prompt-recall.mjs b/integrations/codex/hooks/dist/impl/prompt-recall.mjs index 62410b7..3d0db36 100755 --- a/integrations/codex/hooks/dist/impl/prompt-recall.mjs +++ b/integrations/codex/hooks/dist/impl/prompt-recall.mjs @@ -10,6 +10,40 @@ var __esm = (fn, res, err) => function __init() { } }; +// ../claude-code/lib/handles.mjs +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} +function stripHandles(text) { + return String(text ?? "").replace(TAG_RE, " "); +} +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + // ../claude-code/lib/assemble.mjs function sectionFor(entryType) { const t = typeof entryType === "string" ? entryType.trim().toLowerCase() : ""; @@ -34,12 +68,14 @@ function assembleContext(evidence, opts = {}) { if (!isObject(e)) continue; const text = oneLine(e.content); if (!text) continue; - const section = sectionFor(str(e.origin_entry_type) || str(e.entry_type)); + const type = (str(e.origin_entry_type) || str(e.entry_type)).toLowerCase(); + const section = sectionFor(type); if (allowed && !allowed.has(section)) continue; candidates++; const bucket = bySection.get(section) ?? []; bucket.push({ ref: str(e.reference_id), + type, text, score: finite(e.score, 0), stale: e.is_stale === true, @@ -55,12 +91,14 @@ function assembleContext(evidence, opts = {}) { sourceRefIds: [], dropped: 0, pointers: 0, - emptyReason: "no_evidence" + emptyReason: "no_evidence", + entries: [] }; } const parts = []; const sourceRefIds = []; const seenRefs = /* @__PURE__ */ new Set(); + const entries = []; const sections = []; let used = 0; let rendered = 0; @@ -78,9 +116,10 @@ function assembleContext(evidence, opts = {}) { if (perSection > 0 && count >= perSection) { continue; } - const full = `- ${item.stale ? "(stale) " : ""}${item.text} + const tag = handleTag(item.ref); + const full = `- ${tag ? `${tag} ` : ""}${item.stale ? "(stale) " : ""}${item.text} `; - const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${item.ref} \u2014 ${firstClause(item.text)} + const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${tag} \u2014 ${firstClause(item.text)} ` : ""; const degraded = !!pointer && pointer.length < full.length; const line = degraded ? pointer : full; @@ -99,6 +138,18 @@ function assembleContext(evidence, opts = {}) { seenRefs.add(item.ref); sourceRefIds.push(item.ref); } + if (!item.ref || entries.every((x) => x.ref !== item.ref)) { + entries.push({ + ref: item.ref, + handle: handleFor(item.ref), + section: key, + type: item.type, + text: item.text, + title: firstClause(item.text), + pointer: degraded, + stale: item.stale + }); + } } if (count > 0) sections.push({ section: key, count }); } @@ -114,7 +165,8 @@ function assembleContext(evidence, opts = {}) { // A degraded entry is rendered, not dropped: it counts here and nowhere else, so a // reader can tell a block that shrank from a block that lost half its evidence. pointers, - emptyReason: rendered > 0 ? "" : "budget_exhausted" + emptyReason: rendered > 0 ? "" : "budget_exhausted", + entries: rendered > 0 ? entries : [] }; } function isPointerLine(line) { @@ -162,6 +214,7 @@ function positiveInt(v, d) { var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS, DEFAULT_TOKEN_BUDGET, CHARS_PER_TOKEN, MAX_ITEM_CHARS, POINTER_MARK, MAX_POINTER_CHARS, MIN_POINTER_CHARS; var init_assemble = __esm({ "../claude-code/lib/assemble.mjs"() { + init_handles(); SECTION_KEYS = Object.freeze([ "mental_models", "active_rules", @@ -667,6 +720,7 @@ function takeCarry(cfg, runId) { pointers: int(raw.pointers, 0), emptyReason: typeof raw.empty_reason === "string" ? raw.empty_reason : "", refIds, + entries: entriesOf(raw.entries), writtenAt, forPromptId: typeof raw.for_prompt_id === "string" ? raw.for_prompt_id : "", fetchMs: int(raw.fetch_ms, 0) @@ -675,6 +729,10 @@ function takeCarry(cfg, runId) { return null; } } +function entriesOf(v) { + if (!Array.isArray(v)) return []; + return v.filter((e) => isObject2(e) && typeof e.ref === "string").slice(0, MAX_ENTRIES); +} function isObject2(v) { return !!v && typeof v === "object" && !Array.isArray(v); } @@ -686,11 +744,12 @@ function int(v, d) { const n = num2(v, NaN); return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : d; } -var CARRY_TTL_MS; +var CARRY_TTL_MS, MAX_ENTRIES; var init_carry = __esm({ "../claude-code/lib/carry.mjs"() { init_state(); CARRY_TTL_MS = 15 * 60 * 1e3; + MAX_ENTRIES = 64; } }); @@ -904,6 +963,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -977,6 +1046,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -1122,7 +1193,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MAX_ENV_TAGS = 8; MODE = "hosted"; LANG_FILES = [ @@ -1779,7 +1850,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -1869,7 +1940,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1951,7 +2022,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -2042,13 +2113,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -2564,30 +2635,30 @@ async function ladder(cfg, o) { limit: QUERY_LIMIT, entry_types: [...ENTRY_TYPES], include_working_memory: true, - // `env_tags` is accepted on the query route but not on the context route — version-aware - // tag scoring is capability rungs 1-2 gain over rung 3, not something they give up. + // `env_tags` is accepted on the query route but not on the context route — a capability + // rungs 1-2 gain over rung 3, not something they give up. // Tagged from the directory this prompt was sent in, not the one the session launched - // in — the same reason the run id reads the payload. A recall scored against `repo:` - // tags from the wrong repo is worse than one scored against none. + // in — the same reason the run id reads the payload. A recall tagged with the wrong repo + // is worse than an untagged one. env_tags: envTags(cfg, o.projectDir), // `rank_by` is the same trap as `env_tags` above, one field further on. `/context` // accepts no ranking field of ANY kind, // which makes freshness the second capability rungs 1-2 gain over rung 3 rather than // something they give up. What makes it a trap rather than a limitation: turning rung 3 // on (`recallAssemble: "server"`) does not fail, warn, or fall back: it silently reverts - // every recall to the default fusion weights, and "where were we?" quietly goes back to + // every recall to the default ordering, and "where were we?" quietly goes back to // answering with whatever is most similar. Documented in the README's `recallAssemble` // row for the same reason. // // Omitted rather than sent when it resolves to nothing: absent IS `relevance` // server-side, so there is no shape of request this spread cannot express. ...rankBy ? { rank_by: rankBy } : {}, - // §5.2: opting out of the cross-run lesson overlay, and the ONLY field here that is sent - // to make the request cheaper rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. + // Opting out of cross-run lessons, and the ONLY field here that is sent to make the + // request faster rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. // - // Omitted rather than sent as `false` when the overlay is wanted: absent IS `false` - // server-side, and a request log that only ever shows the field when somebody declined - // the lane is easier to read than one where every request carries it. + // Omitted rather than sent as `false` when they are wanted: absent IS `false`, and a + // request log that only ever shows the field when somebody declined them is easier to + // read than one where every request carries it. ...crossRun ? {} : { prefer_current_run: true } }; let denied = readPolicyDenial(cfg); @@ -2602,7 +2673,7 @@ async function ladder(cfg, o) { if (res2.status === 403) { cachePolicyDenial(cfg); if (cfg.recallFallback !== "agent_routed") { - log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to pay an LLM call per prompt instead.', { run_id: o.runId }); + log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to accept a slower recall instead.', { run_id: o.runId }); return empty(1, "policy_denied"); } log( @@ -2667,7 +2738,8 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function fromEvidence(cfg, responseBody, rung, o) { @@ -2689,9 +2761,10 @@ function fromEvidence(cfg, responseBody, rung, o) { dropped: a.dropped, pointers: a.pointers, emptyReason: a.emptyReason, - // §4.10/§5.5: a degraded entry is still in here. Dropping a repeat would break + // A degraded entry is still in here. Dropping a repeat would break // attribution for exactly the memories that are helping most. - refIds: a.sourceRefIds + refIds: a.sourceRefIds, + entries: a.entries }; } function tokenBudgetOf(cfg, o) { @@ -2719,7 +2792,8 @@ function empty(rung, reason) { dropped: 0, pointers: 0, emptyReason: reason, - refIds: [] + refIds: [], + entries: [] }; } function failure(state, error, rung) { @@ -2733,6 +2807,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; @@ -2930,7 +3005,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -3261,9 +3336,238 @@ var init_seen = __esm({ } }); +// ../claude-code/lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync4, + readFileSync as readFileSync7, + readSync, + statSync as statSync6, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname6, join as join16 } from "node:path"; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join16(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname6(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} +var SCORE_LOG_VERSION, SCORE_DIR, SCORE_LOG_TTL_MS, MAX_READ_BYTES, MAX_ID; +var init_scorecard_log = __esm({ + "../claude-code/lib/scorecard-log.mjs"() { + init_state(); + SCORE_LOG_VERSION = 1; + SCORE_DIR = "scorecard"; + SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + MAX_READ_BYTES = 4 * 1024 * 1024; + MAX_ID = 128; + } +}); + +// ../claude-code/lib/terms.mjs +function termSet(s) { + const set = /* @__PURE__ */ new Set(); + for (const m of String(s ?? "").matchAll(TERM_RE)) set.add(m[0].toLowerCase()); + return set; +} +function vocabularyOf(block) { + const lines = String(block ?? "").split("\n"); + const bullets = lines.filter((l) => l.startsWith("- ")); + if (bullets.length === 0) return stripHandles(lines.filter((l) => !l.startsWith("#")).join("\n")); + return bullets.filter((l) => !isPointerLine(l)).map((l) => stripHandles(l.slice(2)).trimStart().replace(/^\(stale\)\s+/, "")).join("\n"); +} +function memoryTerms(cfg, blocks, prompt) { + try { + let text = (Array.isArray(blocks) ? blocks : []).filter(Boolean).map(vocabularyOf).filter(Boolean).join("\n"); + if (!text) return []; + try { + text = String(redactText(text, cfg, "output")?.text ?? ""); + } catch { + return []; + } + text = text.replace(PLACEHOLDER_RE, " "); + const fromPrompt = termSet(String(prompt ?? "").slice(0, MAX_PROMPT_SCAN)); + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of text.matchAll(TERM_RE)) { + const t = m[0].toLowerCase(); + if (seen.has(t) || fromPrompt.has(t) || TERM_STOPWORDS.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= MAX_RECALL_TERMS) break; + } + return out; + } catch { + return []; + } +} +function entryTerms(cfg, text, promptTerms = /* @__PURE__ */ new Set(), max = MAX_ENTRY_TERMS) { + try { + const raw = stripHandles(String(text ?? "")); + if (!raw.trim()) return []; + const scrubbed = String(redactText(raw, cfg, "output")?.text ?? "").replace(PLACEHOLDER_RE, " "); + const skip = promptTerms instanceof Set ? promptTerms : /* @__PURE__ */ new Set(); + const cap = Number.isFinite(max) && max > 0 ? Math.trunc(max) : MAX_ENTRY_TERMS; + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of scrubbed.matchAll(TERM_RE)) { + const t = m[0].toLowerCase(); + if (seen.has(t) || skip.has(t) || TERM_STOPWORDS.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= cap) break; + } + return out; + } catch { + return []; + } +} +function entryTitle(cfg, text, max = MAX_TITLE_CHARS) { + try { + const whole = String(text ?? "").replace(/\s+/g, " ").trim(); + let clause = firstClause(whole); + if (clause.endsWith("\u2026") && whole.replace(/[.;!?]+$/, "") === clause.slice(0, -1)) { + clause = clause.slice(0, -1); + } + const t = String(redactText(clause, cfg, "output")?.text ?? "").trim(); + const cap = Number.isFinite(max) && max > 1 ? Math.trunc(max) : MAX_TITLE_CHARS; + return t.length > cap ? `${t.slice(0, cap - 1).trimEnd()}\u2026` : t; + } catch { + return ""; + } +} +var TERM_RE, TERM_STOPWORDS, MAX_RECALL_TERMS, MAX_PROMPT_SCAN, MAX_ANSWER_SCAN, MAX_ENTRY_TERMS, MAX_TITLE_CHARS, PLACEHOLDER_RE; +var init_terms = __esm({ + "../claude-code/lib/terms.mjs"() { + init_assemble(); + init_handles(); + init_redact(); + TERM_RE = /[A-Za-z][A-Za-z0-9_]{3,23}/g; + TERM_STOPWORDS = /* @__PURE__ */ new Set([ + "about", + "after", + "again", + "against", + "also", + "always", + "another", + "because", + "been", + "before", + "being", + "between", + "both", + "called", + "does", + "doing", + "done", + "each", + "else", + "even", + "ever", + "every", + "from", + "have", + "here", + "html", + "http", + "https", + "into", + "just", + "like", + "made", + "make", + "many", + "more", + "most", + "much", + "must", + "need", + "never", + "next", + "once", + "only", + "other", + "over", + "part", + "same", + "says", + "send", + "sent", + "should", + "since", + "some", + "such", + "take", + "than", + "that", + "their", + "them", + "then", + "there", + "these", + "they", + "this", + "those", + "through", + "thing", + "time", + "under", + "until", + "very", + "want", + "well", + "were", + "what", + "when", + "where", + "which", + "while", + "will", + "with", + "without", + "would", + "your" + ]); + MAX_RECALL_TERMS = 48; + MAX_PROMPT_SCAN = 16 * 1024; + MAX_ANSWER_SCAN = 64 * 1024; + MAX_ENTRY_TERMS = 32; + MAX_TITLE_CHARS = 48; + PLACEHOLDER_RE = /\[REDACTED:[^\]]*\]/gi; + } +}); + // ../claude-code/hooks/src/prompt-recall.mjs var prompt_recall_exports = {}; -import { join as join16 } from "node:path"; +import { join as join17 } from "node:path"; function carryForward(cfg, payload, runId, sessionId, started, pins, resume = null) { const promptId = safeId(turnKey(payload)); const carry = takeCarry(cfg, runId); @@ -3307,7 +3611,7 @@ function carryForward(cfg, payload, runId, sessionId, started, pins, resume = nu spawnRefresh(cfg, payload, runId); } if (!rendered && !resume) return pinsOnly(cfg, pins, runId, true); - return injection(runId, rendered ? carry : NO_RECALL, resume, pins, ms, true); + return injection(cfg, runId, rendered ? carry : NO_RECALL, resume, pins, ms, true); } function claimResume(cfg, runId, sessionId) { try { @@ -3347,9 +3651,10 @@ function spawnRefresh(cfg, payload, runId) { function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) { try { if (!promptId) return; - const file = join16(resolveDataDir(cfg), "runs", safeId(runId), "turns", `${promptId}.json`); + const file = join17(resolveDataDir(cfg), "runs", safeId(runId), "turns", `${promptId}.json`); const prev = readJson(file, null); const base = isObject10(prev) ? prev : {}; + const prevRecall = isObject10(base.recall) ? base.recall : null; const next = { ...base, prompt_id: promptId, @@ -3357,6 +3662,7 @@ function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) // stages ids, so that they too can be reinforced or corrected. Deduped: the same // entry reached through two lanes must not be reinforced twice for one turn. recalled: [.../* @__PURE__ */ new Set([ + ...Array.isArray(base.recalled) ? base.recalled.filter((v) => typeof v === "string" && v) : [], ...claimStandingLessons(cfg, runId), ...resume ? resume.refIds : [], ...outcome.refIds @@ -3366,7 +3672,7 @@ function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) rung: outcome.rung, sources: (outcome.refIds.length || outcome.sources) + (resume ? resume.refIds.length || resume.sources : 0), tokens: outcome.tokens + (resume ? resume.tokens : 0), - // The token figure is a four-chars-per-token estimate (§4.10). Characters are what + // The token figure is a four-chars-per-token estimate. Characters are what // was actually injected, so a later reader can re-derive the estimate rather than // inherit it. chars: outcome.block.length + (resume ? resume.block.length : 0), @@ -3379,36 +3685,67 @@ function persistRecalled(cfg, runId, promptId, payload, outcome, resume = null) terms: memoryTerms(cfg, [resume?.block ?? "", outcome.block], str5(payload?.prompt)) } }; + if (prevRecall) { + for (const k of ["sources", "tokens", "chars", "pointers"]) { + next.recall[k] = (Number(prevRecall[k]) || 0) + (Number(next.recall[k]) || 0); + } + const terms = Array.isArray(prevRecall.terms) ? prevRecall.terms : []; + next.recall.terms = [.../* @__PURE__ */ new Set([...terms, ...next.recall.terms])]; + } if (typeof next.session_id !== "string") next.session_id = str5(payload?.session_id); if (!Number.isFinite(next.started_at)) next.started_at = Date.now(); + const promptTerms = termSet(str5(payload?.prompt).slice(0, MAX_PROMPT_SCAN)); + const entries = (Array.isArray(outcome.entries) ? outcome.entries : []).filter((e) => e && typeof e.ref === "string" && e.ref); + const shown = entries.map((e) => ({ + ref: e.ref, + handle: str5(e.handle), + type: str5(e.type), + pointer: e.pointer === true, + terms: entryTerms(cfg, str5(e.text), promptTerms) + })); + next.shown = mergeShown(Array.isArray(base.shown) ? base.shown : [], shown); writeJsonAtomic(file, next); + if (cfg.capture !== false) { + appendShownRow(cfg, payload, promptId, entries, shown, outcome, resume); + } } catch (err) { log(cfg, "warn", `prompt-recall: could not stage recalled ids (${messageOf3(err)})`, { run_id: runId }); } } -function memoryTerms(cfg, blocks, prompt) { +function mergeShown(prev, fresh2) { + const out = prev.filter((e) => isObject10(e) && typeof e.ref === "string" && e.ref); + for (const e of fresh2) { + const at = out.findIndex((o) => o.ref === e.ref); + if (at < 0) out.push(e); + else if (out[at].pointer === true && !e.pointer) out[at] = e; + } + return out; +} +function appendShownRow(cfg, payload, promptId, entries, shown, outcome, resume) { try { - let text = blocks.filter(Boolean).map(vocabularyOf).filter(Boolean).join("\n"); - if (!text) return []; - try { - text = str5(redactText(text, cfg, "output")?.text) || ""; - } catch { - return []; - } - text = text.replace(/\[REDACTED:[^\]]*\]/gi, " "); - const fromPrompt = termSet(prompt.slice(0, MAX_PROMPT_SCAN)); - const out = []; - const seen = /* @__PURE__ */ new Set(); - for (const m of text.matchAll(TERM_RE)) { - const t = m[0].toLowerCase(); - if (seen.has(t) || fromPrompt.has(t) || TERM_STOPWORDS.has(t)) continue; - seen.add(t); - out.push(t); - if (out.length >= MAX_RECALL_TERMS) break; - } - return out; + const lessons = {}; + entries.forEach((e, i) => { + if (str5(e.type) !== "lesson") return; + lessons[e.ref] = { + title: entryTitle(cfg, str5(e.text)), + terms: shown[i].terms, + handle: shown[i].handle, + pointer: shown[i].pointer + }; + }); + const refs = [...new Set([ + ...outcome.refIds, + ...entries.map((e) => e.ref), + ...resume ? resume.refIds : [] + ].filter((r) => typeof r === "string" && r))]; + appendScoreRow(cfg, str5(payload?.session_id), { + kind: "shown", + prompt_id: promptId, + lessons, + refs, + tokens: outcome.tokens + (resume ? resume.tokens : 0) + }); } catch { - return []; } } function claimStandingLessons(cfg, runId) { @@ -3423,17 +3760,6 @@ function claimStandingLessons(cfg, runId) { return []; } } -function vocabularyOf(block) { - const lines = String(block ?? "").split("\n"); - const bullets = lines.filter((l) => l.startsWith("- ")); - if (bullets.length === 0) return lines.filter((l) => !l.startsWith("#")).join("\n"); - return bullets.filter((l) => !isPointerLine(l)).map((l) => l.slice(2).replace(/^\(stale\)\s+/, "")).join("\n"); -} -function termSet(s) { - const set = /* @__PURE__ */ new Set(); - for (const m of String(s ?? "").matchAll(TERM_RE)) set.add(m[0].toLowerCase()); - return set; -} function noteFailure(cfg, runId, outcome, ms) { const state = str5(outcome.state); const known = isConnState2(state); @@ -3478,7 +3804,7 @@ function breakerOpen(cfg) { return false; } } -function injection(runId, outcome, resume, pins, ms, carried = false) { +function injection(cfg, runId, outcome, resume, pins, ms, carried = false) { const recallSources = outcome.refIds.length || outcome.sources; const resumeSources = resume ? resume.refIds.length || resume.sources : 0; const sources = recallSources + resumeSources; @@ -3488,7 +3814,7 @@ function injection(runId, outcome, resume, pins, ms, carried = false) { if (resume) parts.push(resumeWrap(runId, resumeSources, resume.tokens, resume.block)); if (outcome.block || pinned) { parts.push( - wrap(runId, recallSources, outcome.tokens, outcome.block, outcome.pointers, carried, pinned) + wrap(cfg, runId, recallSources, outcome.tokens, outcome.block, outcome.pointers, carried, pinned) ); } return { @@ -3515,7 +3841,7 @@ function pinsOnly(cfg, pins, runId, stamped = false) { return { hookSpecificOutput: { hookEventName: "UserPromptSubmit", - additionalContext: wrap(runId, 0, 0, "", 0, false, pins) + additionalContext: wrap(cfg, runId, 0, 0, "", 0, false, pins) }, systemMessage: `mubit: ${n} pinned${DOT}${formatTokens(pins.tokens)} tok`, suppressOutput: true @@ -3529,11 +3855,12 @@ function pinsGate(cfg, payload) { return SUPPRESS; } } -function wrap(runId, sources, tokens, block, pointers = 0, carried = false, pins = null) { +function wrap(cfg, runId, sources, tokens, block, pointers = 0, carried = false, pins = null) { const pinned = pins && pins.text ? pins : null; const recalled = typeof block === "string" && block !== ""; + const nudge = recalled && HANDLE_LINE_RE.test(block) && cfg?.outcomeReview !== "off"; return ` -` + (pinned ? `${pinned.text}${recalled ? "Those were pinned for this run and hold until they are cleared. Everything below them was retrieved for this prompt.\n" : ""}` : "") + (recalled ? "Recalled from memory of earlier work \u2014 it may be incomplete or out of date, so verify against the code before relying on it.\n" : "") + (recalled && carried ? "It was retrieved against the previous message in this conversation, not this one, so treat it as background rather than as an answer to what was just asked.\n" : "") + (recalled && pointers > 0 ? `A line marked "${POINTER_MARK}" was injected in full earlier in this conversation and is repeated here only as a reference; ask mubit_dereference for its text. +` + (pinned ? `${pinned.text}${recalled ? "Those were pinned for this run and hold until they are cleared. Everything below them was retrieved for this prompt.\n" : ""}` : "") + (recalled ? "Recalled from memory of earlier work \u2014 it may be incomplete or out of date, so verify against the code before relying on it.\n" : "") + (nudge ? "Each entry starts with its id in brackets. Before you finish, report the entries that helped or misled you with mubit_outcome, passing those ids.\n" : "") + (recalled && carried ? "It was retrieved against the previous message in this conversation, not this one, so treat it as background rather than as an answer to what was just asked.\n" : "") + (recalled && pointers > 0 ? `A line marked "${POINTER_MARK}" was injected in full earlier in this conversation and is repeated here only as a reference; ask mubit_dereference with its id for the text. ` : "") + (recalled ? ` ${block.replace(/\s+$/, "")} ` : "") + ""; @@ -3543,7 +3870,7 @@ function formatTokens(n) { return t >= 1e3 ? `${(t / 1e3).toFixed(1)}k` : String(t); } function safeId(v) { - return safeSegment(v, MAX_ID); + return safeSegment(v, MAX_ID2); } function isObject10(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -3593,7 +3920,7 @@ function messageOf3(err) { return "unknown error"; } } -var MIN_PROMPT_CHARS, MAX_QUERY_CHARS, DOT, MAX_RECALL_TERMS, TERM_RE, MAX_PROMPT_SCAN, TERM_STOPWORDS, MAX_ID, CFG, RECALL_BUDGET_MS, HARNESS_BUDGET_MS, SUPPRESS, NO_RECALL; +var MIN_PROMPT_CHARS, MAX_QUERY_CHARS, DOT, HANDLE_LINE_RE, MAX_ID2, CFG, RECALL_BUDGET_MS, HARNESS_BUDGET_MS, SUPPRESS, NO_RECALL; var init_prompt_recall = __esm({ async "../claude-code/hooks/src/prompt-recall.mjs"() { init_assemble(); @@ -3610,100 +3937,14 @@ var init_prompt_recall = __esm({ init_redact(); init_runid(); init_seen(); + init_scorecard_log(); init_state(); + init_terms(); MIN_PROMPT_CHARS = 8; MAX_QUERY_CHARS = 2e3; DOT = " \xB7 "; - MAX_RECALL_TERMS = 48; - TERM_RE = /[A-Za-z][A-Za-z0-9_]{3,23}/g; - MAX_PROMPT_SCAN = 16 * 1024; - TERM_STOPWORDS = /* @__PURE__ */ new Set([ - "about", - "after", - "again", - "against", - "also", - "always", - "another", - "because", - "been", - "before", - "being", - "between", - "both", - "called", - "does", - "doing", - "done", - "each", - "else", - "even", - "ever", - "every", - "from", - "have", - "here", - "html", - "http", - "https", - "into", - "just", - "like", - "made", - "make", - "many", - "more", - "most", - "much", - "must", - "need", - "never", - "next", - "once", - "only", - "other", - "over", - "part", - "same", - "says", - "send", - "sent", - "should", - "since", - "some", - "such", - "take", - "than", - "that", - "their", - "them", - "then", - "there", - "these", - "they", - "this", - "those", - "through", - "thing", - "time", - "under", - "until", - "very", - "want", - "well", - "were", - "what", - "when", - "where", - "which", - "while", - "will", - "with", - "without", - "would", - "your" - ]); - MAX_ID = 128; + HANDLE_LINE_RE = /^- (?:\(seen earlier\) )?(?:\(stale\) )?\[m[a-z2-9]{4}\]/m; + MAX_ID2 = 128; CFG = safeConfig2(); RECALL_BUDGET_MS = clampInt(CFG.recallBudgetMs, 1500, 50, 1e4); HARNESS_BUDGET_MS = Math.min(RECALL_BUDGET_MS + 400, 2800); @@ -3717,7 +3958,8 @@ var init_prompt_recall = __esm({ dropped: 0, pointers: 0, emptyReason: "", - refIds: Object.freeze([]) + refIds: Object.freeze([]), + entries: Object.freeze([]) }); await runHook("prompt-recall", { budgetMs: HARNESS_BUDGET_MS, @@ -3762,7 +4004,7 @@ var init_prompt_recall = __esm({ if (!resume) return pinsOnly(cfg, pins, runId); persistRecalled(cfg, runId, safeId(turnKey(payload)), payload, NO_RECALL, resume); if (pins.text) updateMarker(cfg, runId, { recall: { pin_tokens: pins.tokens } }); - return injection(runId, NO_RECALL, resume, pins, Date.now() - started); + return injection(cfg, runId, NO_RECALL, resume, pins, Date.now() - started); } const query = prompt.slice(0, MAX_QUERY_CHARS); const rankBy = rankForRecall(cfg, query); @@ -3784,7 +4026,7 @@ var init_prompt_recall = __esm({ if (!resume) return pinsOnly(cfg, pins, runId); persistRecalled(cfg, runId, promptId, payload, NO_RECALL, resume); if (pins.text) updateMarker(cfg, runId, { recall: { pin_tokens: pins.tokens } }); - return injection(runId, NO_RECALL, resume, pins, ms); + return injection(cfg, runId, NO_RECALL, resume, pins, ms); } persistRecalled(cfg, runId, promptId, payload, outcome, resume); markSeen(cfg, runId, outcome.refIds, sessionId); @@ -3806,7 +4048,7 @@ var init_prompt_recall = __esm({ } }); if (!outcome.block && !resume) return pinsOnly(cfg, pins, runId, true); - return injection(runId, outcome, resume, pins, ms); + return injection(cfg, runId, outcome, resume, pins, ms); } }); } diff --git a/integrations/codex/hooks/dist/impl/recall-refresh.mjs b/integrations/codex/hooks/dist/impl/recall-refresh.mjs index e5d077c..b2dbe3a 100755 --- a/integrations/codex/hooks/dist/impl/recall-refresh.mjs +++ b/integrations/codex/hooks/dist/impl/recall-refresh.mjs @@ -370,6 +370,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -443,6 +453,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -588,7 +600,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MAX_ENV_TAGS = 8; MODE = "hosted"; LANG_FILES = [ @@ -1486,12 +1498,20 @@ function writeCarry(cfg, runId, outcome, meta = {}) { dropped: int2(outcome?.dropped, 0), pointers: int2(outcome?.pointers, 0), empty_reason: typeof outcome?.emptyReason === "string" ? outcome.emptyReason : "", - ref_ids: Array.isArray(outcome?.refIds) ? outcome.refIds.filter((v) => typeof v === "string" && v.trim()) : [] + ref_ids: Array.isArray(outcome?.refIds) ? outcome.refIds.filter((v) => typeof v === "string" && v.trim()) : [], + entries: entriesOf(outcome?.entries) }); } catch { return false; } } +function entriesOf(v) { + if (!Array.isArray(v)) return []; + return v.filter((e) => isObject2(e) && typeof e.ref === "string").slice(0, MAX_ENTRIES); +} +function isObject2(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} function num2(v, d) { const n = typeof v === "number" ? v : Number(v); return Number.isFinite(n) ? n : d; @@ -1500,11 +1520,12 @@ function int2(v, d) { const n = num2(v, NaN); return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : d; } -var CARRY_TTL_MS; +var CARRY_TTL_MS, MAX_ENTRIES; var init_carry = __esm({ "../claude-code/lib/carry.mjs"() { init_state(); CARRY_TTL_MS = 15 * 60 * 1e3; + MAX_ENTRIES = 64; } }); @@ -1517,7 +1538,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -1586,6 +1607,37 @@ var init_markers = __esm({ } }); +// ../claude-code/lib/handles.mjs +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + // ../claude-code/lib/assemble.mjs function sectionFor(entryType) { const t = typeof entryType === "string" ? entryType.trim().toLowerCase() : ""; @@ -1597,7 +1649,7 @@ function estimateTokens(text) { return Math.ceil(text.length / CHARS_PER_TOKEN); } function assembleContext(evidence, opts = {}) { - const o = isObject2(opts) ? opts : {}; + const o = isObject3(opts) ? opts : {}; const budget = positiveInt2(o.tokenBudget, DEFAULT_TOKEN_BUDGET); const perSection = positiveInt2(o.perSection, 0); const allowed = Array.isArray(o.sections) && o.sections.length ? new Set(o.sections.filter((s) => typeof s === "string").map((s) => s.trim())) : null; @@ -1607,15 +1659,17 @@ function assembleContext(evidence, opts = {}) { let candidates = 0; for (let i = 0; i < list2.length; i++) { const e = list2[i]; - if (!isObject2(e)) continue; + if (!isObject3(e)) continue; const text = oneLine(e.content); if (!text) continue; - const section = sectionFor(str2(e.origin_entry_type) || str2(e.entry_type)); + const type = (str2(e.origin_entry_type) || str2(e.entry_type)).toLowerCase(); + const section = sectionFor(type); if (allowed && !allowed.has(section)) continue; candidates++; const bucket = bySection.get(section) ?? []; bucket.push({ ref: str2(e.reference_id), + type, text, score: finite(e.score, 0), stale: e.is_stale === true, @@ -1631,12 +1685,14 @@ function assembleContext(evidence, opts = {}) { sourceRefIds: [], dropped: 0, pointers: 0, - emptyReason: "no_evidence" + emptyReason: "no_evidence", + entries: [] }; } const parts = []; const sourceRefIds = []; const seenRefs = /* @__PURE__ */ new Set(); + const entries = []; const sections = []; let used = 0; let rendered = 0; @@ -1654,9 +1710,10 @@ function assembleContext(evidence, opts = {}) { if (perSection > 0 && count >= perSection) { continue; } - const full = `- ${item.stale ? "(stale) " : ""}${item.text} + const tag = handleTag(item.ref); + const full = `- ${tag ? `${tag} ` : ""}${item.stale ? "(stale) " : ""}${item.text} `; - const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${item.ref} \u2014 ${firstClause(item.text)} + const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${tag} \u2014 ${firstClause(item.text)} ` : ""; const degraded = !!pointer && pointer.length < full.length; const line = degraded ? pointer : full; @@ -1675,6 +1732,18 @@ function assembleContext(evidence, opts = {}) { seenRefs.add(item.ref); sourceRefIds.push(item.ref); } + if (!item.ref || entries.every((x) => x.ref !== item.ref)) { + entries.push({ + ref: item.ref, + handle: handleFor(item.ref), + section: key, + type: item.type, + text: item.text, + title: firstClause(item.text), + pointer: degraded, + stale: item.stale + }); + } } if (count > 0) sections.push({ section: key, count }); } @@ -1690,7 +1759,8 @@ function assembleContext(evidence, opts = {}) { // A degraded entry is rendered, not dropped: it counts here and nowhere else, so a // reader can tell a block that shrank from a block that lost half its evidence. pointers, - emptyReason: rendered > 0 ? "" : "budget_exhausted" + emptyReason: rendered > 0 ? "" : "budget_exhausted", + entries: rendered > 0 ? entries : [] }; } function firstClause(text) { @@ -1719,7 +1789,7 @@ function oneLine(v) { function str2(v) { return typeof v === "string" ? v.trim() : ""; } -function isObject2(v) { +function isObject3(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function finite(v, d) { @@ -1735,6 +1805,7 @@ function positiveInt2(v, d) { var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS, DEFAULT_TOKEN_BUDGET, CHARS_PER_TOKEN, MAX_ITEM_CHARS, POINTER_MARK, MAX_POINTER_CHARS, MIN_POINTER_CHARS; var init_assemble = __esm({ "../claude-code/lib/assemble.mjs"() { + init_handles(); SECTION_KEYS = Object.freeze([ "mental_models", "active_rules", @@ -1819,7 +1890,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1901,7 +1972,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1992,13 +2063,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -2165,11 +2236,11 @@ function readRules(cfg, runId) { try { if (!runId) return []; const stored = readJson(join9(runDir(cfg, runId), RULES_FILE), null); - if (!isObject3(stored)) return []; + if (!isObject4(stored)) return []; if (!Array.isArray(stored.rules)) return []; const out = []; for (const r of stored.rules) { - if (!isObject3(r)) continue; + if (!isObject4(r)) continue; const text = clamp(r.text); if (!text) continue; out.push({ ref: segment(r.ref), text }); @@ -2181,7 +2252,7 @@ function readRules(cfg, runId) { } } function normalise(e) { - if (!isObject3(e)) return null; + if (!isObject4(e)) return null; if (e.is_stale === true) return null; const type = str3(e.origin_entry_type) || str3(e.entry_type) || str3(e.lesson_type); if (type.toLowerCase() !== "rule") return null; @@ -2216,7 +2287,7 @@ function segment(v) { function str3(v) { return typeof v === "string" ? v.trim() : ""; } -function isObject3(v) { +function isObject4(v) { return !!v && typeof v === "object" && !Array.isArray(v); } var RULES_FILE, VERSION, MAX_RULES, MAX_TEXT, MAX_REF; @@ -2257,30 +2328,30 @@ async function ladder(cfg, o) { limit: QUERY_LIMIT, entry_types: [...ENTRY_TYPES], include_working_memory: true, - // `env_tags` is accepted on the query route but not on the context route — version-aware - // tag scoring is capability rungs 1-2 gain over rung 3, not something they give up. + // `env_tags` is accepted on the query route but not on the context route — a capability + // rungs 1-2 gain over rung 3, not something they give up. // Tagged from the directory this prompt was sent in, not the one the session launched - // in — the same reason the run id reads the payload. A recall scored against `repo:` - // tags from the wrong repo is worse than one scored against none. + // in — the same reason the run id reads the payload. A recall tagged with the wrong repo + // is worse than an untagged one. env_tags: envTags(cfg, o.projectDir), // `rank_by` is the same trap as `env_tags` above, one field further on. `/context` // accepts no ranking field of ANY kind, // which makes freshness the second capability rungs 1-2 gain over rung 3 rather than // something they give up. What makes it a trap rather than a limitation: turning rung 3 // on (`recallAssemble: "server"`) does not fail, warn, or fall back: it silently reverts - // every recall to the default fusion weights, and "where were we?" quietly goes back to + // every recall to the default ordering, and "where were we?" quietly goes back to // answering with whatever is most similar. Documented in the README's `recallAssemble` // row for the same reason. // // Omitted rather than sent when it resolves to nothing: absent IS `relevance` // server-side, so there is no shape of request this spread cannot express. ...rankBy ? { rank_by: rankBy } : {}, - // §5.2: opting out of the cross-run lesson overlay, and the ONLY field here that is sent - // to make the request cheaper rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. + // Opting out of cross-run lessons, and the ONLY field here that is sent to make the + // request faster rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. // - // Omitted rather than sent as `false` when the overlay is wanted: absent IS `false` - // server-side, and a request log that only ever shows the field when somebody declined - // the lane is easier to read than one where every request carries it. + // Omitted rather than sent as `false` when they are wanted: absent IS `false`, and a + // request log that only ever shows the field when somebody declined them is easier to + // read than one where every request carries it. ...crossRun ? {} : { prefer_current_run: true } }; let denied = readPolicyDenial(cfg); @@ -2295,7 +2366,7 @@ async function ladder(cfg, o) { if (res2.status === 403) { cachePolicyDenial(cfg); if (cfg.recallFallback !== "agent_routed") { - log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to pay an LLM call per prompt instead.', { run_id: o.runId }); + log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to accept a slower recall instead.', { run_id: o.runId }); return empty(1, "policy_denied"); } log( @@ -2346,11 +2417,11 @@ async function rungThree(cfg, o) { return fromContext(res.body, 3); } function fromContext(responseBody, rung) { - const b = isObject4(responseBody) ? responseBody : {}; + const b = isObject5(responseBody) ? responseBody : {}; const block = typeof b.context_block === "string" ? b.context_block.trim() : ""; const refIds = Array.isArray(b.sources) ? [...new Set(b.sources.filter((s) => typeof s === "string" && s.trim()).map((s) => s.trim()))] : []; const summaries = Array.isArray(b.section_summaries) ? b.section_summaries : []; - const counted = summaries.reduce((n, s) => n + (isObject4(s) ? numOr(s.count, 0) : 0), 0); + const counted = summaries.reduce((n, s) => n + (isObject5(s) ? numOr(s.count, 0) : 0), 0); return { failed: false, rung, @@ -2360,11 +2431,12 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function fromEvidence(cfg, responseBody, rung, o) { - const b = isObject4(responseBody) ? responseBody : {}; + const b = isObject5(responseBody) ? responseBody : {}; const evidence = Array.isArray(b.evidence) ? b.evidence : []; if (o?.runId) recordRules(cfg, o.runId, evidence); const a = assembleContext(evidence, { @@ -2382,9 +2454,10 @@ function fromEvidence(cfg, responseBody, rung, o) { dropped: a.dropped, pointers: a.pointers, emptyReason: a.emptyReason, - // §4.10/§5.5: a degraded entry is still in here. Dropping a repeat would break + // A degraded entry is still in here. Dropping a repeat would break // attribution for exactly the memories that are helping most. - refIds: a.sourceRefIds + refIds: a.sourceRefIds, + entries: a.entries }; } function tokenBudgetOf(cfg, o) { @@ -2412,7 +2485,8 @@ function empty(rung, reason) { dropped: 0, pointers: 0, emptyReason: reason, - refIds: [] + refIds: [], + entries: [] }; } function failure(state, error, rung) { @@ -2426,6 +2500,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; @@ -2438,7 +2513,7 @@ function policyPath(cfg) { function readPolicyDenial(cfg) { try { const v = readJson(policyPath(cfg), null); - if (!isObject4(v) || v.direct_bypass !== "denied") return false; + if (!isObject5(v) || v.direct_bypass !== "denied") return false; const ttl = intOr(cfg.policyTtlMs, 0) || intOr(v.ttl_ms, 0) || POLICY_TTL_MS; const observed = numOr(v.observed_at, 0); return observed > 0 && Date.now() - observed < ttl; @@ -2467,7 +2542,7 @@ function remaining(cfg, deadline2) { if (left <= 0) return 0; return Math.max(1, Math.min(left, intOr(cfg.timeoutMs, 4e3))); } -function isObject4(v) { +function isObject5(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function numOr(v, d) { @@ -2533,9 +2608,9 @@ function agentRole(env = process.env) { return AGENT_ROLES[host2] ?? DEFAULT_AGENT_ROLE; } function deriveRunId(cfg, payload = {}, options = {}) { - const c = isObject5(cfg) ? cfg : {}; - const p = isObject5(payload) ? payload : {}; - const persist = !(isObject5(options) && options.persist === false); + const c = isObject6(cfg) ? cfg : {}; + const p = isObject6(payload) ? payload : {}; + const persist = !(isObject6(options) && options.persist === false); return assertUsableRunId(resolveRunId(c, p, persist)); } function resolveRunId(cfg, payload, persist) { @@ -2604,7 +2679,7 @@ function directoryRunId(cfg, payload, withBranch) { return branch ? `cc-${slug}-${branch}-${digest}` : `cc-${slug}-${digest}`; } function reusableRun(cfg, payload, prev, strategy) { - if (!isObject5(prev)) return ""; + if (!isObject6(prev)) return ""; const id = typeof prev.run_id === "string" ? prev.run_id.trim() : ""; if (!id || FORBIDDEN_RUN_IDS.has(id.toLowerCase())) return ""; const recorded = typeof prev.strategy === "string" ? prev.strategy.trim() : ""; @@ -2623,20 +2698,20 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; } function turnKey(payload) { - if (!isObject5(payload)) return ""; + if (!isObject6(payload)) return ""; const prompt = typeof payload.prompt_id === "string" ? payload.prompt_id.trim() : ""; if (prompt) return prompt; const turn = typeof payload.turn_id === "string" ? payload.turn_id.trim() : ""; return turn; } function deriveAgentId(payload = {}) { - const p = isObject5(payload) ? payload : {}; + const p = isObject6(payload) ? payload : {}; const role = agentRole(); const sub = subagentShort(p); return sub ? `${role}-sub-${sub}` : role; @@ -2663,7 +2738,7 @@ function loadSessionMap(sessionId) { const file = sessionFileName(sessionId); if (!file) return null; const stored = readJson(sessionPath(file), null); - return isObject5(stored) ? stored : null; + return isObject6(stored) ? stored : null; } catch { return null; } @@ -2671,10 +2746,10 @@ function loadSessionMap(sessionId) { function rememberRun(cfg, payload, sessionId, prev, next) { const now = Date.now(); const isSessionStart = !!next.source || payload.hook_event_name === "SessionStart"; - const moved = !isObject5(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; - const lastSeen = isObject5(prev) ? numberOr2(prev.last_seen_at, 0) : 0; + const moved = !isObject6(prev) || prev.run_id !== next.run_id || clearCount(prev) !== next.clear_count; + const lastSeen = isObject6(prev) ? numberOr2(prev.last_seen_at, 0) : 0; if (!moved && !isSessionStart && now - lastSeen < TOUCH_INTERVAL_MS) return; - const inherited = isObject5(prev) ? prev : {}; + const inherited = isObject6(prev) ? prev : {}; const dir = projectDirOf(cfg, payload); saveSessionMap(sessionId, { ...inherited, @@ -2708,7 +2783,7 @@ function normaliseRecord(record) { clear_count: 0, endpoint_hash: "" }; - if (isObject5(record)) { + if (isObject6(record)) { for (const [k, v] of Object.entries(record)) { if (v !== void 0) out[k] = v; } @@ -2725,10 +2800,10 @@ function sessionFileName(sessionId) { return safe && safe !== "." && safe !== ".." ? safe : ""; } function projectDirOf(cfg, payload = {}) { - return usableDir(isObject5(payload) ? payload.cwd : "") || firstString(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); + return usableDir(isObject6(payload) ? payload.cwd : "") || firstString(cfg.projectDir, process.env.CLAUDE_PROJECT_DIR) || safeCwd2(); } function resolveProjectDir(cfg, payload = {}) { - return projectDirOf(isObject5(cfg) ? cfg : {}, payload); + return projectDirOf(isObject6(cfg) ? cfg : {}, payload); } function projectRootOf(dir) { return gitToplevel2(dir) || dir; @@ -2776,7 +2851,7 @@ function hasGitDir2(start) { } return false; } -function isObject5(v) { +function isObject6(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function firstString(...vals) { @@ -2790,7 +2865,7 @@ function numberOr2(v, d) { return Number.isFinite(n) ? n : d; } function clearCount(rec) { - if (!isObject5(rec)) return 0; + if (!isObject6(rec)) return 0; const n = Math.trunc(numberOr2(rec.clear_count, 0)); return n > 0 ? n : 0; } @@ -2803,7 +2878,7 @@ function normaliseSource(v) { return SOURCES.has(s) ? s : ""; } function hostSessionId(payload) { - const v = isObject5(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; + const v = isObject6(payload) && typeof payload.session_id === "string" ? payload.session_id.trim() : ""; if (!v || PLACEHOLDER_SESSION_IDS.has(v.toLowerCase())) return ""; return v; } @@ -2866,12 +2941,12 @@ function readSeen(cfg, runId, sessionId = "") { const p = seenPath(cfg, runId, sessionId); if (!p) return emptySeen(); const raw = readJson(p, null); - if (!isObject6(raw) || !isObject6(raw.refs)) return emptySeen(); + if (!isObject7(raw) || !isObject7(raw.refs)) return emptySeen(); const cutoff = Date.now() - SEEN_TTL_MS; const out = emptySeen(); out.updatedAt = num3(raw.updated_at, 0); for (const [id, v] of Object.entries(raw.refs)) { - if (!id || !id.trim() || !isObject6(v)) continue; + if (!id || !id.trim() || !isObject7(v)) continue; const last = num3(v.last, 0); if (!(last > 0) || last < cutoff) continue; const first = num3(v.first, 0); @@ -2887,7 +2962,7 @@ function readSeen(cfg, runId, sessionId = "") { return emptySeen(); } } -function isObject6(v) { +function isObject7(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function num3(v, d) { @@ -2974,7 +3049,7 @@ var init_recall_refresh = __esm({ query, deadline: started + REFRESH_BUDGET_MS, seen, - // §5.2 — the same rule over the same query text `prompt-recall` would have used. + // The same rule over the same query text `prompt-recall` would have used. // Carry-forward moves WHEN the call happens, never what it asks for: a handoff prompt // ranked by similarity in the background is the same bug, one turn later. rankBy: rankForRecall(cfg, query), diff --git a/integrations/codex/hooks/dist/impl/session-end.mjs b/integrations/codex/hooks/dist/impl/session-end.mjs index b0b28a2..6f079f2 100755 --- a/integrations/codex/hooks/dist/impl/session-end.mjs +++ b/integrations/codex/hooks/dist/impl/session-end.mjs @@ -153,6 +153,9 @@ function pruneStale(cfg = {}) { for (const name of jsonFiles(join2(root, "import"))) { expire(join2(root, "import", name), 30 * DAY); } + for (const e of dirEntries(join2(root, "scorecard"))) { + if (e.isFile() && e.name.endsWith(".jsonl")) expire(join2(root, "scorecard", e.name), 7 * DAY); + } for (const e of dirEntries(join2(root, "tmp"))) { if (e.isFile()) expire(join2(root, "tmp", e.name), 1 * HOUR); } @@ -660,6 +663,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -733,6 +746,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -878,7 +893,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -894,15 +909,50 @@ function implicitOutcomesEnabled(cfg) { function decideOutcome(turn) { if (!isObject(turn)) return { post: false, reason: "not_a_turn" }; if (numOr(turn.outcome_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "corrected" }; if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; if (numOr(turn.outcome_attempts, 0) >= MAX_OUTCOME_ATTEMPTS) { return { post: false, reason: "attempts_exhausted" }; } - const entryIds = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; - if (entryIds.length === 0) return { post: false, reason: "nothing_injected" }; - const failed = str2(turn.outcome).toLowerCase() === "failure"; + const recalled = Array.isArray(turn.recalled) ? turn.recalled.filter((v) => typeof v === "string" && v.trim()) : []; const ev = isObject(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (recalled.length === 0 && !entries) return { post: false, reason: "nothing_injected" }; + const failed = str2(turn.outcome).toLowerCase() === "failure"; + const toolFailure = failed && str2(turn.failure_reason) === "tool_failure"; + if (entries) { + const refs = Object.keys(entries); + const used = refs.filter((r) => entries[r].used === true); + const measured = refs.some((r) => entries[r].used === false); + if (used.length > 0) { + const explicit2 = new Set(explicitIdsOf(turn)); + const ids2 = used.filter((r) => !explicit2.has(r)); + if (ids2.length === 0) return { post: false, reason: "explicit_only" }; + return { + post: true, + outcome: failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, + signal: failed ? SIGNAL_FAILURE : SIGNAL_SUCCESS, + entryIds: ids2, + rationale: entryRationale(ev, used.length, refs.length, failed, toolFailure) + }; + } + if (measured && explicitIdsOf(turn).length > 0) return { post: false, reason: "explicit_only" }; + if (measured) { + return { + post: true, + outcome: OUTCOME_UNUSED, + signal: SIGNAL_UNUSED, + entryIds: [], + rationale: entryRationale(ev, 0, refs.length, failed, toolFailure) + }; + } + if (recalled.length === 0) return { post: false, reason: "nothing_injected" }; + } const unused = ev.used === false; + const explicit = new Set(explicitIdsOf(turn)); + if (unused && explicit.size > 0) return { post: false, reason: "explicit_only" }; + const ids = recalled.filter((r) => !explicit.has(r)); + if (!unused && ids.length === 0) return { post: false, reason: "explicit_only" }; return { post: true, outcome: unused ? OUTCOME_UNUSED : failed ? OUTCOME_FAILURE : OUTCOME_SUCCESS, @@ -912,8 +962,36 @@ function decideOutcome(turn) { // The cost is that the record says a turn was injected-and-unused // without saying which entries were ignored — a real limitation, and the honest side of // the trade. - entryIds: unused ? [] : entryIds, - rationale: rationaleFor(ev, unused, failed, entryIds.length) + entryIds: unused ? [] : ids, + rationale: rationaleFor(ev, unused, failed, recalled.length, toolFailure) + }; +} +function decideCorrection(turn) { + if (!isObject(turn)) return { post: false, reason: "not_a_turn" }; + if (numOr(turn.correction_sent_at, 0) > 0) return { post: false, reason: "already_sent" }; + if (str2(turn[API_ERROR_KEY])) return { post: false, reason: "api_failed" }; + if (str2(turn.outcome).toLowerCase() === "failure") return { post: false, reason: "already_failed" }; + const ev = isObject(turn.used_evidence) ? turn.used_evidence : {}; + const entries = entriesOf(ev); + if (!entries) return { post: false, reason: "nothing_used" }; + const explicit = new Set(explicitIdsOf(turn)); + const ids = Object.keys(entries).filter((r) => entries[r].used === true && !explicit.has(r)); + if (ids.length === 0) return { post: false, reason: "nothing_used" }; + return { + post: true, + outcome: OUTCOME_FAILURE, + signal: SIGNAL_FAILURE, + entryIds: ids, + rationale: `The user's next prompt corrected this Claude Code turn; the reply had used ${ids.length} ${ids.length === 1 ? "memory" : "memories"} (memory-term-echo/v2-entry).` + }; +} +function correctionIdempotencyKey(runId, promptId) { + return `cc-correction-${str2(runId)}-${str2(promptId)}`; +} +function correctionRequest(o) { + return { + ...outcomeRequest(o), + idempotency_key: correctionIdempotencyKey(o.runId, o.promptId) }; } function outcomeIdempotencyKey(runId, promptId) { @@ -932,17 +1010,40 @@ function outcomeRequest(o) { idempotency_key: outcomeIdempotencyKey(o.runId, o.promptId) }; } -function rationaleFor(ev, unused, failed, n) { +function rationaleFor(ev, unused, failed, n, toolFailure = false) { const method = str2(ev.method); const by = method ? ` (${method})` : ""; const counts = `${numOr(ev.matched, 0)} of ${numOr(ev.candidates, 0)} injected memory terms`; if (unused) { return `Claude Code injected ${n} ${n === 1 ? "memory" : "memories"} and the reply carried none of their vocabulary \u2014 ${counts}${by}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; } + const ended = toolFailure ? "Claude Code turn ended on a failed tool call" : "Claude Code turn ended in failure"; if (ev.used === true) { - return failed ? `Claude Code turn ended in failure; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + return failed ? `${ended}; the reply carried ${counts}${by}.` : `Claude Code turn completed and the reply carried ${counts}${by}.`; + } + return failed ? `${ended} after these memories were injected.` : "Claude Code turn completed after these memories were injected."; +} +function entryRationale(ev, used, of, failed, toolFailure) { + const method = str2(ev.entry_method) || "memory-term-echo/v2-entry"; + const counts = `the reply used ${used} of ${of} injected ${of === 1 ? "memory" : "memories"} (${method})`; + if (used === 0) { + return `Claude Code turn completed; ${counts}. Recorded, not penalised: this method cannot see memory the model followed without quoting it.`; + } + if (!failed) return `Claude Code turn completed; ${counts}.`; + return toolFailure ? `Claude Code turn ended on a failed tool call; ${counts}.` : `Claude Code turn ended in failure; ${counts}.`; +} +function entriesOf(ev) { + const e = ev.entries; + if (!isObject(e)) return null; + const out = {}; + for (const [ref, v] of Object.entries(e)) { + if (ref.trim() && isObject(v)) out[ref] = /** @type {any} */ + v; } - return failed ? "Claude Code turn ended in failure after these memories were injected." : "Claude Code turn completed after these memories were injected."; + return Object.keys(out).length ? out : null; +} +function explicitIdsOf(turn) { + return Array.isArray(turn.explicit_ids) ? turn.explicit_ids.filter((v) => typeof v === "string" && v.trim()) : []; } function isObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); @@ -1034,9 +1135,9 @@ function scrub(text, count) { } function entropy(s) { if (s === null || s === void 0) return 0; - const str4 = typeof s === "string" ? s : String(s); - if (str4.length === 0) return 0; - const buf = Buffer.from(str4, "utf8"); + const str5 = typeof s === "string" ? s : String(s); + if (str5.length === 0) return 0; + const buf = Buffer.from(str5, "utf8"); const n = buf.length; if (n === 0) return 0; const counts = new Uint32Array(256); @@ -1349,7 +1450,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1419,7 +1520,7 @@ async function postOutcome(cfg, req, opts = {}) { req, "reference_id", "postOutcome", - 'pass "global" for run-level attribution and put the real ids in entry_ids[] (\xA71.3)' + 'pass "global" for run-level attribution and put the real ids in entry_ids[]' ) ); if (bad) return refuse(cfg, started, bad, { route: ROUTES.outcome }); @@ -1448,7 +1549,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1539,7 +1640,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireItems(req) { const items = req && typeof req === "object" ? req.items : void 0; @@ -2080,7 +2181,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -2259,7 +2360,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2476,29 +2577,176 @@ var init_runid = __esm({ } }); -// ../claude-code/lib/spool.mjs +// ../claude-code/lib/scorecard-log.mjs import { closeSync as closeSync3, - existsSync as existsSync7, - linkSync, + fstatSync as fstatSync2, openSync as openSync3, readdirSync as readdirSync4, readFileSync as readFileSync8, - renameSync as renameSync4, + readSync as readSync2, statSync as statSync7, + writeSync as writeSync4 +} from "node:fs"; +import { dirname as dirname7, join as join11 } from "node:path"; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join11(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function readScoreRows(cfg, sessionId, opts = {}) { + const p = scorecardPath(cfg, sessionId); + if (!p) return []; + return readRowsAt(p, opts); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync7(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync3(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync2(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync3(fd); + } + partialHead = true; + } else { + text = readFileSync8(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} +var SCORE_DIR, SCORE_LOG_TTL_MS, MAX_READ_BYTES, MAX_ID; +var init_scorecard_log = __esm({ + "../claude-code/lib/scorecard-log.mjs"() { + init_state(); + SCORE_DIR = "scorecard"; + SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + MAX_READ_BYTES = 4 * 1024 * 1024; + MAX_ID = 128; + } +}); + +// ../claude-code/lib/scorecard.mjs +function previousTurn(rows, currentPromptId) { + const list2 = Array.isArray(rows) ? rows.filter(isObject5) : []; + const current = str3(currentPromptId); + const prompts = /* @__PURE__ */ new Map(); + const order = []; + const turns = /* @__PURE__ */ new Map(); + const clears = []; + list2.forEach((row, pos) => { + if (row.kind === "start" && str3(row.source) === "clear") clears.push(pos); + const id2 = str3(row.prompt_id); + if (!id2 || !PROMPT_KINDS.has(row.kind)) return; + if (!prompts.has(id2)) { + prompts.set(id2, markOf(pos)); + order.push(id2); + } + const p = ( + /** @type {PromptMark} */ + prompts.get(id2) + ); + if (row.kind === "prompt") flagPrompt(p, row); + if (row.kind === "turn") turns.set(id2, row); + }); + const at = order.indexOf(current); + const before = (at >= 0 ? order.slice(0, at) : order).filter((id2) => !prompts.get(id2)?.slash); + const id = before[before.length - 1]; + if (!id) return null; + const from = num3(prompts.get(id)?.pos); + const to = at >= 0 ? num3(prompts.get(current)?.pos) : Infinity; + return { promptId: id, turn: turns.get(id) ?? null, afterClear: clears.some((c) => c > from && c < to) }; +} +function correctionTargets(rows) { + const list2 = Array.isArray(rows) ? rows.filter(isObject5) : []; + const out = []; + const seen = /* @__PURE__ */ new Set(); + list2.forEach((row, i) => { + if (row.kind !== "prompt" || row.correction !== true || row.slash === true) return; + const prev = previousTurn(list2.slice(0, i), str3(row.prompt_id)); + const turn = prev?.turn; + if (!prev || !turn || prev.afterClear) return; + const used = Array.isArray(turn.used_refs) && turn.used_refs.some((r) => typeof r === "string" && r); + const runId = str3(turn.run_id); + const key = `${runId} +${prev.promptId}`; + if (!used || !runId || seen.has(key)) return; + seen.add(key); + out.push({ runId, promptId: prev.promptId }); + }); + return out; +} +function markOf(pos) { + return { slash: false, correction: false, pos, flagged: false }; +} +function flagPrompt(p, row) { + if (p.flagged) return; + p.flagged = true; + p.slash = row.slash === true; + p.correction = row.correction === true; +} +function isObject5(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} +function str3(v) { + return typeof v === "string" ? v : ""; +} +function num3(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} +var PROMPT_KINDS; +var init_scorecard = __esm({ + "../claude-code/lib/scorecard.mjs"() { + PROMPT_KINDS = /* @__PURE__ */ new Set(["prompt", "shown", "turn"]); + } +}); + +// ../claude-code/lib/spool.mjs +import { + closeSync as closeSync4, + existsSync as existsSync7, + linkSync, + openSync as openSync4, + readdirSync as readdirSync5, + readFileSync as readFileSync9, + renameSync as renameSync4, + statSync as statSync8, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4, - writeSync as writeSync4 + writeSync as writeSync5 } from "node:fs"; import { createHash as createHash4, randomBytes } from "node:crypto"; -import { join as join11 } from "node:path"; +import { join as join12 } from "node:path"; function batchIdempotencyKey(runId, items) { const ids = (Array.isArray(items) ? items : []).map((it) => it && typeof it === "object" ? String(it.item_id ?? "") : "").join("|"); const digest = createHash4("sha256").update(`${String(runId ?? "")}|${ids}`, "utf8").digest("hex").slice(0, 16); return `cc-batch-${digest}`; } function spoolDir(cfg, runId) { - return join11(runDir(cfg, runId), "spool"); + return join12(runDir(cfg, runId), "spool"); } function stampOf(dir, name) { const m = /^(\d{10,})-/.exec(name); @@ -2507,7 +2755,7 @@ function stampOf(dir, name) { if (Number.isFinite(n)) return n; } try { - return statSync7(join11(dir, name)).mtimeMs; + return statSync8(join12(dir, name)).mtimeMs; } catch { return 0; } @@ -2515,7 +2763,7 @@ function stampOf(dir, name) { function orderedNames(dir) { let entries; try { - entries = readdirSync4(dir, { withFileTypes: true }); + entries = readdirSync5(dir, { withFileTypes: true }); } catch { return []; } @@ -2528,10 +2776,10 @@ function readBatch(cfg, runId, max = DEFAULT_MAX) { const dir = spoolDir(cfg, runId); for (const name of orderedNames(dir)) { if (out.length >= limit) break; - const path = join11(dir, name); + const path = join12(dir, name); let raw; try { - raw = readFileSync8(path, "utf8"); + raw = readFileSync9(path, "utf8"); } catch { continue; } @@ -2570,14 +2818,14 @@ function stamp(pid, ts) { } function readLock(lockPath) { try { - const raw = readFileSync8(lockPath, "utf8"); + const raw = readFileSync9(lockPath, "utf8"); const j = JSON.parse(raw); if (!j || typeof j !== "object") return null; const pid = Number(j.pid); let ts = Number(j.ts); if (!Number.isFinite(ts)) { try { - ts = statSync7(lockPath).mtimeMs; + ts = statSync8(lockPath).mtimeMs; } catch { return null; } @@ -2603,7 +2851,7 @@ function acquireDrainLock(cfg, runId) { try { const dir = runDir(cfg, runId); if (!ensureDir(dir)) return null; - const lockPath = join11(dir, "drain.lock"); + const lockPath = join12(dir, "drain.lock"); const held = create(lockPath); if (held) return { path: lockPath, runId: String(runId ?? ""), pid: process.pid, ts: held }; const owner = readLock(lockPath); @@ -2641,16 +2889,16 @@ function create(lockPath) { ) return 0; let fd; try { - fd = openSync3(lockPath, "wx"); + fd = openSync4(lockPath, "wx"); } catch { return 0; } try { - writeSync4(fd, body); + writeSync5(fd, body); } catch { } finally { try { - closeSync3(fd); + closeSync4(fd); } catch { } } @@ -2677,7 +2925,7 @@ function acquireFlushLease(cfg, runId, name) { const safe = safeSegment(name); const dir = runDir(cfg, runId); if (!safe || !ensureDir(dir)) return open; - const lockPath = join11(dir, `flush-${safe}.lock`); + const lockPath = join12(dir, `flush-${safe}.lock`); const held = create(lockPath); if (held) return { path: lockPath, runId: String(runId ?? ""), pid: process.pid, ts: held }; if (!existsSync7(lockPath)) return open; @@ -2707,7 +2955,7 @@ function claimHeld(cfg, runId, name) { try { const safe = safeSegment(name); if (!safe) return false; - return existsSync7(join11(runDir(cfg, runId), `${safe}.marker`)); + return existsSync7(join12(runDir(cfg, runId), `${safe}.marker`)); } catch { return false; } @@ -2718,10 +2966,10 @@ function claimOnce(cfg, runId, name) { if (!safe) return true; const dir = runDir(cfg, runId); ensureDir(dir); - const marker = join11(dir, `${safe}.marker`); + const marker = join12(dir, `${safe}.marker`); let fd; try { - fd = openSync3(marker, "wx"); + fd = openSync4(marker, "wx"); } catch (err) { if ( /** @type {any} */ @@ -2730,11 +2978,11 @@ function claimOnce(cfg, runId, name) { return true; } try { - writeSync4(fd, stamp(process.pid, Date.now())); + writeSync5(fd, stamp(process.pid, Date.now())); } catch { } finally { try { - closeSync3(fd); + closeSync4(fd); } catch { } } @@ -2748,7 +2996,7 @@ function spoolStats(cfg, runId) { const dir = spoolDir(cfg, runId); let entries; try { - entries = readdirSync4(dir, { withFileTypes: true }); + entries = readdirSync5(dir, { withFileTypes: true }); } catch { return { count: 0, oldestMs: 0 }; } @@ -2778,8 +3026,8 @@ var init_spool = __esm({ // ../claude-code/hooks/src/session-end.mjs var session_end_exports = {}; -import { readdirSync as readdirSync5, unlinkSync as unlinkSync5 } from "node:fs"; -import { join as join12 } from "node:path"; +import { readdirSync as readdirSync6, unlinkSync as unlinkSync5 } from "node:fs"; +import { join as join13 } from "node:path"; function handOff(cfg, payload, runId) { updateMarker(cfg, runId, { reflect: { at: 0, lessons_stored: 0, status: "handoff" } }); const path = stashPayload(cfg, payload); @@ -2855,14 +3103,14 @@ async function drainInline(cfg, o) { idempotency_key: batchIdempotencyKey(o.runId, items), parallel: true, items, - ...str3(cfg.userId) ? { user_id: str3(cfg.userId) } : {} + ...str4(cfg.userId) ? { user_id: str4(cfg.userId) } : {} }, { timeoutMs: Math.max(1, Math.min(intOr(cfg.timeoutMs, 4e3), o.deadline - Date.now())) }); batches++; if (!res.ok) { log(cfg, "warn", `session-end: ingest failed (${res.state}); items stay spooled`, { run_id: o.runId, status: res.status ?? 0, - error: str3(res.error).slice(0, 300) + error: str4(res.error).slice(0, 300) }); failed = true; break; @@ -2891,16 +3139,16 @@ async function drainInline(cfg, o) { } function recordJob(cfg, runId, body, n) { try { - const jobId = str3(body?.job_id); + const jobId = str4(body?.job_id); if (!jobId) return; - const p = join12(runDir(cfg, runId), "jobs.json"); + const p = join13(runDir(cfg, runId), "jobs.json"); const prev = readJson(p, []); const arr = Array.isArray(prev) ? prev.filter((e) => !!e && typeof e === "object") : []; arr.push({ job_id: jobId, at: Date.now(), items: n, - status: str3(body?.status), + status: str4(body?.status), deduplicated: body?.deduplicated === true }); writeJsonAtomic(p, arr.slice(-JOBS_KEEP)); @@ -2911,19 +3159,19 @@ async function flushOutcomes(cfg, o) { if (!implicitOutcomesEnabled(cfg)) return 0; let flushed = 0; try { - const dir = join12(runDir(cfg, o.runId), "turns"); + const dir = join13(runDir(cfg, o.runId), "turns"); let names = []; try { - names = readdirSync5(dir).filter((f) => f.endsWith(".json")); + names = readdirSync6(dir).filter((f) => f.endsWith(".json")); } catch { return 0; } for (const name of names.slice(0, MAX_TURN_FLUSH)) { const budget = o.budget(); if (budget <= 0) break; - const p = join12(dir, name); + const p = join13(dir, name); const turn = readJson(p, null); - if (!isObject5(turn) || turn.outcome_pending !== true) continue; + if (!isObject6(turn) || turn.outcome_pending !== true) continue; const decision = decideOutcome(turn); if (!decision.post) { if (decision.reason === "attempts_exhausted") { @@ -2931,7 +3179,7 @@ async function flushOutcomes(cfg, o) { } continue; } - const promptId = str3(turn.prompt_id) || name.replace(/\.json$/, ""); + const promptId = str4(turn.prompt_id) || name.replace(/\.json$/, ""); const attempts = numOr2(turn.outcome_attempts, 0); writeJsonAtomic(p, { ...turn, outcome_attempts: attempts + 1 }); const res = await postOutcome( @@ -2972,6 +3220,48 @@ async function flushOutcomes(cfg, o) { } return flushed; } +async function flushCorrections(cfg, o) { + if (!implicitOutcomesEnabled(cfg)) return 0; + let sent = 0; + try { + const targets = correctionTargets(readScoreRows(cfg, o.sessionId)).slice(-MAX_TURN_FLUSH); + for (const { runId, promptId } of targets) { + const budget = o.budget(); + if (budget <= 0) break; + const p = join13(runDir(cfg, runId), "turns", `${safeSegment(promptId)}.json`); + const turn = readJson(p, null); + const decision = decideCorrection(turn); + if (!decision.post) continue; + const res = await postOutcome( + cfg, + correctionRequest({ runId, agentId: o.agentId, promptId, decision }), + { timeoutMs: budget } + ); + if (!res.ok) { + log(cfg, "info", `session-end: correction flush failed (${res.state})`, { run_id: runId, prompt_id: promptId }); + continue; + } + sent++; + const fresh2 = readJson(p, turn); + writeJsonAtomic(p, { ...isObject6(fresh2) ? fresh2 : turn, correction_sent_at: Date.now() }); + appendLedger(resolveDataDir(cfg), runId, { + v: 1, + kind: "outcome", + at: Date.now(), + run_id: runId, + prompt_id: promptId, + outcome: String(decision.outcome ?? ""), + signal: numOr2(decision.signal, 0), + entry_ids_n: Array.isArray(decision.entryIds) ? decision.entryIds.length : 0, + attempts: 1, + correction: true + }); + } + } catch (err) { + log(cfg, "warn", `session-end: correction flush skipped \u2014 ${messageOf2(err)}`, { session_id: o.sessionId }); + } + return sent; +} async function maybeReflect(cfg, o) { const idle = { attempted: false, status: "skipped", lessons: 0, at: 0, attempts: 0, error: "" }; if (cfg.reflectOnEnd === false) { @@ -3014,8 +3304,7 @@ async function maybeReflect(cfg, o) { const reflectBody = { run_id: o.runId, include_linked_runs: false, - // `include_step_outcomes` folds outcome signals into the evidence - // (`control.proto`) — the NEGATIVE ones produce the highest-value lessons. + // `include_step_outcomes`: the outcomes posted above are part of what reflect reads. include_step_outcomes: true, // `last_n_items` bounds the evidence to the most recent items of the run, of every kind // and including those outcomes. A session end is the tail of its run, so that bound is @@ -3023,7 +3312,7 @@ async function maybeReflect(cfg, o) { last_n_items: REFLECT_LAST_N // `record: false`, because a deadline this client chose is not evidence about the server. // `lib/http.mjs` already exempts callers who dial *tighter* than the configured default; - // this one is the mirror image and the exemption misses it — the reflect is LLM-backed + // this one is the mirror image and the exemption misses it — the reflect is slow // and dials deliberately wide, so its abort would be filed as `not_responding` against an // instance that was still composing an answer. Five of those inside the window open the // breaker, and the breaker gates the ingest *drain*: a merely slow reflection would @@ -3067,7 +3356,7 @@ async function maybeReflect(cfg, o) { run_id: o.runId, status: res.status ?? 0, attempts, - error: str3(res.error).slice(0, 300) + error: str4(res.error).slice(0, 300) } ); return { @@ -3076,10 +3365,10 @@ async function maybeReflect(cfg, o) { lessons: 0, at: Date.now(), attempts, - error: str3(res.error) + error: str4(res.error) }; } - const body = isObject5(res.body) ? res.body : {}; + const body = isObject6(res.body) ? res.body : {}; const stored = Number.isFinite(Number(body.lessons_stored)) ? Math.max(0, Math.trunc(Number(body.lessons_stored))) : Array.isArray(body.lessons) ? body.lessons.length : 0; log( cfg, @@ -3100,10 +3389,10 @@ function breakerOpen(cfg) { return false; } } -function isObject5(v) { +function isObject6(v) { return !!v && typeof v === "object" && !Array.isArray(v); } -function str3(v) { +function str4(v) { return typeof v === "string" ? v.trim() : ""; } function numOr2(v, d) { @@ -3135,6 +3424,8 @@ var init_session_end = __esm({ init_markers(); init_outcome(); init_runid(); + init_scorecard_log(); + init_scorecard(); init_spool(); init_state(); DETACHED = process.env.MUBIT_CC_DETACHED === "1"; @@ -3202,6 +3493,10 @@ var init_session_end = __esm({ runId, agentId, budget: () => budgetFor(OUTCOME_MS, REFLECT_MS / 2) + }) + await flushCorrections(cfg, { + sessionId, + agentId, + budget: () => budgetFor(OUTCOME_MS, REFLECT_MS / 2) }); const marker = readMarker(cfg, runId); const priorIngested = numOr2(marker.captured?.ingested, 0); diff --git a/integrations/codex/hooks/dist/impl/session-resume.mjs b/integrations/codex/hooks/dist/impl/session-resume.mjs index 375bf5d..59229cb 100755 --- a/integrations/codex/hooks/dist/impl/session-resume.mjs +++ b/integrations/codex/hooks/dist/impl/session-resume.mjs @@ -306,6 +306,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -379,6 +389,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -524,7 +536,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -1083,6 +1095,18 @@ var init_hook = __esm({ } }); +// ../claude-code/lib/handles.mjs +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + // ../claude-code/lib/assemble.mjs function estimateTokens(text) { if (typeof text !== "string" || text.length === 0) return 0; @@ -1091,6 +1115,7 @@ function estimateTokens(text) { var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS, CHARS_PER_TOKEN; var init_assemble = __esm({ "../claude-code/lib/assemble.mjs"() { + init_handles(); SECTION_KEYS = Object.freeze([ "mental_models", "active_rules", @@ -1420,7 +1445,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1493,7 +1518,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1584,7 +1609,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -1772,7 +1797,8 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function failure(state, error, rung) { @@ -1786,6 +1812,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; diff --git a/integrations/codex/hooks/dist/impl/session-start.mjs b/integrations/codex/hooks/dist/impl/session-start.mjs index 960fba1..3b1520d 100755 --- a/integrations/codex/hooks/dist/impl/session-start.mjs +++ b/integrations/codex/hooks/dist/impl/session-start.mjs @@ -564,6 +564,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -637,6 +647,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -782,7 +794,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); @@ -1433,7 +1445,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1562,7 +1574,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1705,7 +1717,7 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function isPoisonedRunId(body) { return !!body && typeof body === "object" && !Array.isArray(body) && body.run_id === POISONED_RUN_ID; @@ -2100,8 +2112,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -2209,7 +2221,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -2278,6 +2290,128 @@ var init_markers = __esm({ } }); +// ../claude-code/lib/handles.mjs +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} +function stripHandles(text) { + return String(text ?? "").replace(TAG_RE, " "); +} +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + +// ../claude-code/lib/assemble.mjs +function estimateTokens(text) { + if (typeof text !== "string" || text.length === 0) return 0; + return Math.ceil(text.length / CHARS_PER_TOKEN); +} +function firstClause(text) { + const s = typeof text === "string" ? text.trim() : ""; + if (!s) return ""; + const stop = s.search(/[.;!?]/); + let end = stop > 0 ? Math.min(stop, MAX_POINTER_CHARS) : MAX_POINTER_CHARS; + if (end < MIN_POINTER_CHARS) end = Math.min(s.length, MAX_POINTER_CHARS); + const clause = s.slice(0, end).trim() || s.slice(0, MAX_POINTER_CHARS).trim(); + return clause.length < s.length ? `${clause}\u2026` : clause; +} +var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS, CHARS_PER_TOKEN, MAX_POINTER_CHARS, MIN_POINTER_CHARS; +var init_assemble = __esm({ + "../claude-code/lib/assemble.mjs"() { + init_handles(); + SECTION_KEYS = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "archive_blocks", + "handoffs", + "feedback", + "facts", + "observations", + "working_memory", + "traces", + "goals", + "checkpoints", + "logs", + "other" + ]); + EMISSION_ORDER = Object.freeze([ + "mental_models", + "active_rules", + "lessons", + "facts", + "observations", + "working_memory", + "traces", + "goals" + ]); + RENDER_ORDER = Object.freeze([ + ...EMISSION_ORDER, + ...SECTION_KEYS.filter((k) => !EMISSION_ORDER.includes(k)) + ]); + SECTION_BY_ENTRY_TYPE = Object.freeze({ + mental_model: "mental_models", + rule: "active_rules", + lesson: "lessons", + fact: "facts", + observation: "observations", + working_memory: "working_memory", + goal: "working_memory", + trace: "traces", + tool_output: "traces", + tool_input: "traces", + task_result: "traces", + step_outcome: "traces", + archive_block: "archive_blocks", + checkpoint: "checkpoints", + handoff: "handoffs", + feedback: "feedback" + }); + HEADINGS = Object.freeze({ + mental_models: "Mental models", + active_rules: "Active rules", + lessons: "Lessons", + archive_blocks: "Archive blocks", + handoffs: "Handoffs", + feedback: "Feedback", + facts: "Facts", + observations: "Observations", + working_memory: "Working memory", + traces: "Traces", + goals: "Goals", + checkpoints: "Checkpoints", + logs: "Logs", + other: "Other" + }); + CHARS_PER_TOKEN = 4; + MAX_POINTER_CHARS = 64; + MIN_POINTER_CHARS = 24; + } +}); + // ../claude-code/lib/rules.mjs import { join as join10 } from "node:path"; function recordRules(cfg, runId, entries) { @@ -2372,11 +2506,203 @@ var init_rules = __esm({ } }); +// ../claude-code/lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync3, + readFileSync as readFileSync6, + readSync, + statSync as statSync5, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname5, join as join11 } from "node:path"; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join11(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname5(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} +var SCORE_LOG_VERSION, SCORE_DIR, SCORE_LOG_TTL_MS, MAX_READ_BYTES, MAX_ID; +var init_scorecard_log = __esm({ + "../claude-code/lib/scorecard-log.mjs"() { + init_state(); + SCORE_LOG_VERSION = 1; + SCORE_DIR = "scorecard"; + SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + MAX_READ_BYTES = 4 * 1024 * 1024; + MAX_ID = 128; + } +}); + +// ../claude-code/lib/terms.mjs +function entryTerms(cfg, text, promptTerms = /* @__PURE__ */ new Set(), max = MAX_ENTRY_TERMS) { + try { + const raw = stripHandles(String(text ?? "")); + if (!raw.trim()) return []; + const scrubbed = String(redactText(raw, cfg, "output")?.text ?? "").replace(PLACEHOLDER_RE, " "); + const skip = promptTerms instanceof Set ? promptTerms : /* @__PURE__ */ new Set(); + const cap = Number.isFinite(max) && max > 0 ? Math.trunc(max) : MAX_ENTRY_TERMS; + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const m of scrubbed.matchAll(TERM_RE)) { + const t = m[0].toLowerCase(); + if (seen.has(t) || skip.has(t) || TERM_STOPWORDS.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= cap) break; + } + return out; + } catch { + return []; + } +} +function entryTitle(cfg, text, max = MAX_TITLE_CHARS) { + try { + const whole = String(text ?? "").replace(/\s+/g, " ").trim(); + let clause = firstClause(whole); + if (clause.endsWith("\u2026") && whole.replace(/[.;!?]+$/, "") === clause.slice(0, -1)) { + clause = clause.slice(0, -1); + } + const t = String(redactText(clause, cfg, "output")?.text ?? "").trim(); + const cap = Number.isFinite(max) && max > 1 ? Math.trunc(max) : MAX_TITLE_CHARS; + return t.length > cap ? `${t.slice(0, cap - 1).trimEnd()}\u2026` : t; + } catch { + return ""; + } +} +var TERM_RE, TERM_STOPWORDS, MAX_PROMPT_SCAN, MAX_ANSWER_SCAN, MAX_ENTRY_TERMS, MAX_TITLE_CHARS, PLACEHOLDER_RE; +var init_terms = __esm({ + "../claude-code/lib/terms.mjs"() { + init_assemble(); + init_handles(); + init_redact(); + TERM_RE = /[A-Za-z][A-Za-z0-9_]{3,23}/g; + TERM_STOPWORDS = /* @__PURE__ */ new Set([ + "about", + "after", + "again", + "against", + "also", + "always", + "another", + "because", + "been", + "before", + "being", + "between", + "both", + "called", + "does", + "doing", + "done", + "each", + "else", + "even", + "ever", + "every", + "from", + "have", + "here", + "html", + "http", + "https", + "into", + "just", + "like", + "made", + "make", + "many", + "more", + "most", + "much", + "must", + "need", + "never", + "next", + "once", + "only", + "other", + "over", + "part", + "same", + "says", + "send", + "sent", + "should", + "since", + "some", + "such", + "take", + "than", + "that", + "their", + "them", + "then", + "there", + "these", + "they", + "this", + "those", + "through", + "thing", + "time", + "under", + "until", + "very", + "want", + "well", + "were", + "what", + "when", + "where", + "which", + "while", + "will", + "with", + "without", + "would", + "your" + ]); + MAX_PROMPT_SCAN = 16 * 1024; + MAX_ANSWER_SCAN = 64 * 1024; + MAX_ENTRY_TERMS = 32; + MAX_TITLE_CHARS = 48; + PLACEHOLDER_RE = /\[REDACTED:[^\]]*\]/gi; + } +}); + // ../claude-code/lib/runid.mjs import { spawnSync } from "node:child_process"; import { createHash as createHash3 } from "node:crypto"; -import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync5 } from "node:fs"; -import { basename as basename2, dirname as dirname5, join as join11, resolve as resolve4 } from "node:path"; +import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync6 } from "node:fs"; +import { basename as basename2, dirname as dirname6, join as join12, resolve as resolve4 } from "node:path"; function agentRole(env = process.env) { const host2 = typeof env?.MUBIT_CC_HOST === "string" ? env.MUBIT_CC_HOST.trim().toLowerCase() : ""; return AGENT_ROLES[host2] ?? DEFAULT_AGENT_ROLE; @@ -2472,7 +2798,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2558,7 +2884,7 @@ function normaliseRecord(record) { return out; } function sessionPath(file) { - return join11(dataDir({}), "sessions", `${file}.json`); + return join12(dataDir({}), "sessions", `${file}.json`); } function sessionFileName(sessionId) { const raw = typeof sessionId === "string" ? sessionId.trim() : ""; @@ -2576,7 +2902,7 @@ function usableDir(v) { const s = typeof v === "string" ? v.trim() : ""; if (!s) return ""; try { - return statSync5(s).isDirectory() ? s : ""; + return statSync6(s).isDirectory() ? s : ""; } catch { return ""; } @@ -2606,8 +2932,8 @@ function hasGitDir(start) { try { let cur = resolve4(start); for (let i = 0; i < 24; i++) { - if (existsSync6(join11(cur, ".git"))) return true; - const up = dirname5(cur); + if (existsSync6(join12(cur, ".git"))) return true; + const up = dirname6(cur); if (up === cur) return false; cur = up; } @@ -2691,7 +3017,37 @@ var init_runid = __esm({ // ../claude-code/hooks/src/session-start.mjs var session_start_exports = {}; -import { join as join12 } from "node:path"; +import { join as join13 } from "node:path"; +function lessonLines(lessons) { + return lessons.map((l) => { + const tag = handleTag(l.id); + return `- ${tag ? `${tag} ` : ""}[${l.type}] ${l.content}`; + }); +} +function appendStart(cfg, payload, lessons) { + try { + if (cfg.capture === false) return; + const sessionId = typeof payload?.session_id === "string" ? payload.session_id.trim() : ""; + if (!sessionId) return; + const withIds = lessons.filter((l) => l.id); + const rows = {}; + for (const l of withIds) { + rows[l.id] = { + title: entryTitle(cfg, l.content), + terms: entryTerms(cfg, l.content, /* @__PURE__ */ new Set()), + handle: handleFor(l.id) + }; + } + appendScoreRow(cfg, sessionId, { + kind: "start", + source: sourceOf(payload) || "startup", + lessons: rows, + refs: withIds.map((l) => l.id), + tokens: estimateTokens(lessonLines(lessons).join("\n")) + }); + } catch { + } +} function spawnResume(cfg, payload, runId, agentId, src) { try { if (!cfg.resumeBlock || !cfg.recall) return; @@ -2725,6 +3081,9 @@ function steerBlock(cfg, runId, lessons, anchor = "", partial = false) { "Do search when the injected memory falls short: mubit_recall for a topic, mubit_diagnose when a command has failed, mubit_dereference for a reference_id you already hold.", `Save what you learn with mubit_learned, and credit what helped with mubit_outcome. ${skill("remember")} and ${skill("recall")} are the explicit forms.` ); + if (cfg.capture && cfg.outcomeReview !== "off") { + lines.push("Each memory line starts with a short id in brackets, like [m7k2q]: pass it in entry_ids of mubit_outcome to credit or fault that entry."); + } } if (anchor) { lines.push( @@ -2736,7 +3095,7 @@ function steerBlock(cfg, runId, lessons, anchor = "", partial = false) { if (lessons.length || partial) { lines.push("", "## Standing lessons (global)"); lines.push("Learned from earlier work \u2014 they may be out of date, so verify before relying on one."); - for (const l of lessons) lines.push(`- [${l.type}] ${l.content}`); + lines.push(...lessonLines(lessons)); if (partial) { lines.push(`This set may be incomplete \u2014 it was read from a listing with more than this page in it. Ask ${skill("recall")} if a constraint seems to be missing.`); } @@ -2778,7 +3137,7 @@ function armColdStart(cfg) { const grace = Math.max(0, intOr(cfg.coldStartGraceMs, 0)); if (grace <= 0) return 0; try { - const path = join12(dataDir(cfg), "coldstart", `${endpointHash(cfg)}.json`); + const path = join13(dataDir(cfg), "coldstart", `${endpointHash(cfg)}.json`); const stored = readJson(path, null); const until = stored && typeof stored === "object" ? intOr(stored.until, 0) : 0; if (until > 0) return until; @@ -2791,7 +3150,7 @@ function armColdStart(cfg) { } function latestCheckpointId(cfg, runId) { try { - const path = join12(resolveDataDir(cfg), "runs", safeSegment2(runId), "checkpoints.json"); + const path = join13(resolveDataDir(cfg), "runs", safeSegment2(runId), "checkpoints.json"); const stored = readJson(path, []); const list2 = Array.isArray(stored) ? stored : Array.isArray(stored?.checkpoints) ? stored.checkpoints : stored?.items; if (!Array.isArray(list2)) return ""; @@ -2838,7 +3197,7 @@ function readLessons(body) { function probeStatusLine(cfg) { try { if (cfg.statusLine === false) return ""; - const p = join12(dataDir(cfg), LIVENESS_FILE); + const p = join13(dataDir(cfg), LIVENESS_FILE); const now = Date.now(); const rec = readJson(p, null); if (!rec || typeof rec !== "object" || Array.isArray(rec)) { @@ -2863,7 +3222,7 @@ function probeStatusLine(cfg) { notified_at: nag ? now : notifiedAt }); if (!nag) return ""; - log(cfg, "info", "session-start: status line never invoked; emitting the one-time \xA716.2 hint"); + log(cfg, "info", "session-start: status line never invoked; emitting the one-time status-line hint"); return statusLineHint(); } catch { return ""; @@ -2905,7 +3264,11 @@ var init_session_start = __esm({ init_http(); init_log(); init_markers(); + init_assemble(); + init_handles(); init_rules(); + init_scorecard_log(); + init_terms(); init_runid(); init_state(); BUDGET_MS = 2500; @@ -2934,12 +3297,14 @@ var init_session_start = __esm({ agentId = deriveAgentId(payload); } catch (err) { log(cfg, "error", `session-start: no usable run id (${messageOf2(err)})`); + appendStart(cfg, payload, []); return {}; } const statusLineHint2 = probeStatusLine(cfg); if (!isConfigured(cfg)) { updateMarker(cfg, runId, { mode: cfg.mode, state: "unconfigured", cold_start_until: 0, last_error: "" }); log(cfg, "debug", "session-start: no endpoint configured", { run_id: runId }); + appendStart(cfg, payload, []); return { hookSpecificOutput: { hookEventName: "SessionStart", @@ -2968,6 +3333,7 @@ var init_session_start = __esm({ }; if (!warming) out.systemMessage = `mubit: offline (${state})${DOT}capture buffered`; if (statusLineHint2) out.systemMessage = statusLineHint2; + appendStart(cfg, payload, []); return out; } let authError = ""; @@ -3027,6 +3393,7 @@ var init_session_start = __esm({ } }; out.systemMessage = statusLineHint2 || `mubit: auth failed${DOT}capture buffered`; + appendStart(cfg, payload, []); return out; } updateMarker(cfg, runId, { @@ -3048,12 +3415,13 @@ var init_session_start = __esm({ const anchor = src === "compact" ? latestCheckpointId(cfg, runId) : ""; const standing = lessonsPartial ? "global lessons: partial listing" : `${lessons.length} global lesson${lessons.length === 1 ? "" : "s"}`; const summary = `mubit: ${cfg.mode}${DOT}run ${runId}${DOT}${standing}`; + appendStart(cfg, payload, lessons); return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: steerBlock(cfg, runId, lessons, anchor, lessonsPartial) }, - // §16.2's hint fires once, ever, per install, so on that one session it *takes* the + // The status-line hint fires once, ever, per install, so on that one session it *takes* the // line rather than being appended to it: `systemMessage` is one line by contract, and // the run and mode it would displace are already named in the steer block above. systemMessage: statusLineHint2 || summary diff --git a/integrations/codex/hooks/dist/impl/stage-prompt.mjs b/integrations/codex/hooks/dist/impl/stage-prompt.mjs index f6ed860..465415b 100755 --- a/integrations/codex/hooks/dist/impl/stage-prompt.mjs +++ b/integrations/codex/hooks/dist/impl/stage-prompt.mjs @@ -300,6 +300,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -373,6 +383,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -518,11 +530,49 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MODE = "hosted"; } }); +// ../claude-code/lib/correction.mjs +function isCorrection(prompt, opts = {}) { + try { + if (typeof prompt !== "string") return false; + const trimmed = prompt.trim(); + if (!trimmed || trimmed.startsWith("/")) return false; + const text = trimmed.replace(/```[\s\S]*?(?:```|$)/g, " ").replace(/"[^"\n]*"|“[^”\n]*”|`[^`\n]*`/g, " ").replace(/[‘’]/g, "'").trim().slice(0, SCAN_CHARS).toLowerCase(); + if (!text) return false; + const asked = opts?.lastReplyEndedWithQuestion === true; + if (BARE_NO.test(text)) return !asked; + if (!asked && LEADING_NO.test(text) && !POLITE_NO.test(text)) return true; + return PHRASES.some((re) => re.test(text)); + } catch { + return false; + } +} +var SCAN_CHARS, LEADING_NO, POLITE_NO, BARE_NO, PHRASES; +var init_correction = __esm({ + "../claude-code/lib/correction.mjs"() { + SCAN_CHARS = 300; + LEADING_NO = /^(?:no|nope)(?:\s*[,.!;:—–-]|\s*$)/; + POLITE_NO = /^(?:no|nope)[\s,.!]*(?:problem|worries|thanks|thank you|need|rush|biggie)\b/; + BARE_NO = /^(?:no|nope)[\s.!]*$/; + PHRASES = [ + /^(?:wrong|incorrect)\b/, + /\b(?:that'?s|that is|this is|it'?s|it is)\s+(?:wrong|incorrect|not right|not it|not what i (?:asked|wanted|meant))\b/, + /\bnot what i (?:asked|wanted|meant)\b/, + /\b(?:you|it|this|that) broke\b/, + /\bstill (?:failing|fails|broken|erroring|crashing|not working|(?:doesn'?t|does not|isn'?t|is not) work(?:ing)?)\b/, + /\b(?:doesn'?t|does not|didn'?t|did not) work\b/, + /\b(?:revert|undo) (?:that|this|it|the (?:last|previous))\b/, + /\broll back (?:that|this|it|the)\b/, + /\byou misunderstood\b/, + /\byou(?:'ve| have)? got it wrong\b/ + ]; + } +}); + // ../claude-code/lib/redact.mjs function scrubAssignments(text, count) { ASSIGNMENT_RE.lastIndex = 0; @@ -587,9 +637,9 @@ function scrub(text, count) { } function entropy(s) { if (s === null || s === void 0) return 0; - const str2 = typeof s === "string" ? s : String(s); - if (str2.length === 0) return 0; - const buf = Buffer.from(str2, "utf8"); + const str4 = typeof s === "string" ? s : String(s); + if (str4.length === 0) return 0; + const buf = Buffer.from(str4, "utf8"); const n = buf.length; if (n === 0) return 0; const counts = new Uint32Array(256); @@ -1138,6 +1188,24 @@ var init_hook = __esm({ } }); +// ../claude-code/lib/outcome.mjs +function implicitOutcomesEnabled(cfg) { + const mode = str2(cfg && typeof cfg === "object" ? ( + /** @type {any} */ + cfg.outcomeMode + ) : "").toLowerCase(); + return !SILENCED_MODES.has(mode); +} +function str2(v) { + return typeof v === "string" ? v.trim() : ""; +} +var SILENCED_MODES; +var init_outcome = __esm({ + "../claude-code/lib/outcome.mjs"() { + SILENCED_MODES = /* @__PURE__ */ new Set(["off", "explicit"]); + } +}); + // ../claude-code/lib/runid.mjs import { spawnSync } from "node:child_process"; import { createHash as createHash2 } from "node:crypto"; @@ -1238,7 +1306,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -1446,23 +1514,178 @@ var init_runid = __esm({ } }); -// ../claude-code/lib/spool.mjs +// ../claude-code/lib/scorecard-log.mjs import { closeSync as closeSync2, - existsSync as existsSync7, - linkSync, + fstatSync, openSync as openSync2, readdirSync as readdirSync4, readFileSync as readFileSync7, - renameSync as renameSync3, + readSync, statSync as statSync6, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname6, join as join8 } from "node:path"; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join8(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname6(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} +function readScoreRows(cfg, sessionId, opts = {}) { + const p = scorecardPath(cfg, sessionId); + if (!p) return []; + return readRowsAt(p, opts); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync6(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync2(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync2(fd); + } + partialHead = true; + } else { + text = readFileSync7(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} +var SCORE_LOG_VERSION, SCORE_DIR, SCORE_LOG_TTL_MS, MAX_READ_BYTES, MAX_ID; +var init_scorecard_log = __esm({ + "../claude-code/lib/scorecard-log.mjs"() { + init_state(); + SCORE_LOG_VERSION = 1; + SCORE_DIR = "scorecard"; + SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + MAX_READ_BYTES = 4 * 1024 * 1024; + MAX_ID = 128; + } +}); + +// ../claude-code/lib/scorecard.mjs +function previousTurn(rows, currentPromptId) { + const list2 = Array.isArray(rows) ? rows.filter(isObject3) : []; + const current = str3(currentPromptId); + const prompts = /* @__PURE__ */ new Map(); + const order = []; + const turns = /* @__PURE__ */ new Map(); + const clears = []; + list2.forEach((row, pos) => { + if (row.kind === "start" && str3(row.source) === "clear") clears.push(pos); + const id2 = str3(row.prompt_id); + if (!id2 || !PROMPT_KINDS.has(row.kind)) return; + if (!prompts.has(id2)) { + prompts.set(id2, markOf(pos)); + order.push(id2); + } + const p = ( + /** @type {PromptMark} */ + prompts.get(id2) + ); + if (row.kind === "prompt") flagPrompt(p, row); + if (row.kind === "turn") turns.set(id2, row); + }); + const at = order.indexOf(current); + const before = (at >= 0 ? order.slice(0, at) : order).filter((id2) => !prompts.get(id2)?.slash); + const id = before[before.length - 1]; + if (!id) return null; + const from = num(prompts.get(id)?.pos); + const to = at >= 0 ? num(prompts.get(current)?.pos) : Infinity; + return { promptId: id, turn: turns.get(id) ?? null, afterClear: clears.some((c) => c > from && c < to) }; +} +function markOf(pos) { + return { slash: false, correction: false, pos, flagged: false }; +} +function flagPrompt(p, row) { + if (p.flagged) return; + p.flagged = true; + p.slash = row.slash === true; + p.correction = row.correction === true; +} +function isObject3(v) { + return !!v && typeof v === "object" && !Array.isArray(v); +} +function str3(v) { + return typeof v === "string" ? v : ""; +} +function num(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} +var PROMPT_KINDS; +var init_scorecard = __esm({ + "../claude-code/lib/scorecard.mjs"() { + PROMPT_KINDS = /* @__PURE__ */ new Set(["prompt", "shown", "turn"]); + } +}); + +// ../claude-code/lib/spool.mjs +import { + closeSync as closeSync3, + existsSync as existsSync7, + linkSync, + openSync as openSync3, + readdirSync as readdirSync5, + readFileSync as readFileSync8, + renameSync as renameSync3, + statSync as statSync7, unlinkSync as unlinkSync4, writeFileSync as writeFileSync3, - writeSync as writeSync3 + writeSync as writeSync4 } from "node:fs"; -import { join as join8 } from "node:path"; +import { join as join9 } from "node:path"; function spoolDir(cfg, runId) { - return join8(runDir(cfg, runId), "spool"); + return join9(runDir(cfg, runId), "spool"); } function stampOf(dir, name) { const m = /^(\d{10,})-/.exec(name); @@ -1471,7 +1694,7 @@ function stampOf(dir, name) { if (Number.isFinite(n)) return n; } try { - return statSync6(join8(dir, name)).mtimeMs; + return statSync7(join9(dir, name)).mtimeMs; } catch { return 0; } @@ -1481,7 +1704,7 @@ function spoolStats(cfg, runId) { const dir = spoolDir(cfg, runId); let entries; try { - entries = readdirSync4(dir, { withFileTypes: true }); + entries = readdirSync5(dir, { withFileTypes: true }); } catch { return { count: 0, oldestMs: 0 }; } @@ -1508,17 +1731,17 @@ var init_spool = __esm({ // ../claude-code/hooks/src/stage-prompt.mjs var stage_prompt_exports = {}; import { randomUUID } from "node:crypto"; -import { readdirSync as readdirSync5 } from "node:fs"; -import { join as join9 } from "node:path"; +import { readdirSync as readdirSync6 } from "node:fs"; +import { join as join10 } from "node:path"; function stageTurn(cfg, runId, payload) { try { - const promptId = safeSegment(turnKey(payload), MAX_ID); + const promptId = safeSegment(turnKey(payload), MAX_ID2); if (!promptId) return false; - const dir = join9(runDir(cfg, runId), "turns"); + const dir = join10(runDir(cfg, runId), "turns"); if (!ensureDir(dir)) return false; - const file = join9(dir, `${promptId}.json`); + const file = join10(dir, `${promptId}.json`); const prev = readJson(file, null); - const base = isObject3(prev) ? prev : {}; + const base = isObject4(prev) ? prev : {}; const { text, truncated } = clampPrompt(payload?.prompt); const next = { ...base, @@ -1526,11 +1749,12 @@ function stageTurn(cfg, runId, payload) { prompt_id: promptId, session_id: typeof payload?.session_id === "string" ? payload.session_id : "", started_at: Number.isFinite(base.started_at) ? base.started_at : Date.now(), - // The other half of the §5.3 race: never overwrite ids `prompt-recall` already staged. + // The other half of the staging race: never overwrite ids `prompt-recall` already staged. recalled: Array.isArray(base.recalled) ? base.recalled : [], turn_number: Number.isFinite(base.turn_number) && base.turn_number > 0 ? base.turn_number : ordinalFor(dir, !!prev) }; if (truncated) next.prompt_truncated = true; + if (typeof base.prompt === "string") next.queued_at = Date.now(); return writeJsonAtomic(file, next); } catch (err) { log(cfg, "warn", `stage-prompt: could not stage the turn (${messageOf(err)})`, { run_id: runId }); @@ -1539,12 +1763,41 @@ function stageTurn(cfg, runId, payload) { } function ordinalFor(dir, mine) { try { - const n = readdirSync5(dir).filter((f) => f.endsWith(".json")).length; + const n = readdirSync6(dir).filter((f) => f.endsWith(".json")).length; return Math.max(1, mine ? n : n + 1); } catch { return 1; } } +function scorePrompt(cfg, payload) { + try { + const sessionId = typeof payload?.session_id === "string" ? payload.session_id : ""; + const promptId = safeSegment(turnKey(payload), MAX_ID2); + if (!sessionId || !promptId) return; + const prompt = typeof payload?.prompt === "string" ? payload.prompt : ""; + const slash = prompt.trim().startsWith("/"); + const rows = readScoreRows(cfg, sessionId, { tailBytes: LOG_TAIL_BYTES }); + if (rows.some((r) => r.kind === "prompt" && r.prompt_id === promptId)) return; + const before = previousTurn(rows, promptId); + const prev = before?.turn ?? null; + const afterClear = before?.afterClear === true; + const correction = !slash && !!prev && !afterClear && isCorrection(prompt, { lastReplyEndedWithQuestion: prev?.ended_with_question === true }); + appendScoreRow(cfg, sessionId, { kind: "prompt", prompt_id: promptId, correction, slash }); + if (!correction || !prev || !implicitOutcomesEnabled(cfg)) return; + const used = Array.isArray(prev.used_refs) ? prev.used_refs.filter((v) => typeof v === "string" && v.trim()) : []; + const prevRun = typeof prev.run_id === "string" ? prev.run_id : ""; + if (!used.length || !prevRun) return; + spawnDetached( + cfg, + "drain", + ["--correct", String(prev.prompt_id), "--run", prevRun], + writePayload(cfg, payload) + ); + log(cfg, "debug", "stage-prompt: correction of the previous turn", { prompt_id: String(prev.prompt_id) }); + } catch (err) { + log(cfg, "warn", `stage-prompt: could not score the prompt (${messageOf(err)})`); + } +} function maybeDrain(cfg, runId, payload) { try { const { count, oldestMs } = spoolStats(cfg, runId); @@ -1560,10 +1813,10 @@ function maybeDrain(cfg, runId, payload) { } } function writePayload(cfg, payload) { - const dir = join9(resolveDataDir(cfg), "tmp"); - const file = join9(dir, `${randomUUID()}.json`); + const dir = join10(resolveDataDir(cfg), "tmp"); + const file = join10(dir, `${randomUUID()}.json`); ensureDir(dir); - writeJsonAtomic(file, isObject3(payload) ? payload : {}); + writeJsonAtomic(file, isObject4(payload) ? payload : {}); return file; } function clampPrompt(v) { @@ -1571,7 +1824,7 @@ function clampPrompt(v) { if (Buffer.byteLength(s, "utf8") <= MAX_PROMPT_BYTES) return { text: s, truncated: false }; return { text: Buffer.from(s, "utf8").subarray(0, MAX_PROMPT_BYTES).toString("utf8"), truncated: true }; } -function isObject3(v) { +function isObject4(v) { return !!v && typeof v === "object" && !Array.isArray(v); } function intOr(v, d) { @@ -1588,18 +1841,23 @@ function messageOf(err) { } return String(err); } -var BUDGET_MS, MAX_PROMPT_BYTES, MAX_ID; +var BUDGET_MS, MAX_PROMPT_BYTES, MAX_ID2, LOG_TAIL_BYTES; var init_stage_prompt = __esm({ async "../claude-code/hooks/src/stage-prompt.mjs"() { init_config(); + init_correction(); init_hook(); init_log(); + init_outcome(); init_runid(); + init_scorecard_log(); + init_scorecard(); init_spool(); init_state(); BUDGET_MS = 250; MAX_PROMPT_BYTES = 64 * 1024; - MAX_ID = 128; + MAX_ID2 = 128; + LOG_TAIL_BYTES = 64 * 1024; await runHook("stage-prompt", { budgetMs: BUDGET_MS, body: async (payload) => { @@ -1613,6 +1871,7 @@ var init_stage_prompt = __esm({ return { suppressOutput: true }; } stageTurn(cfg, runId, payload); + if (cfg.capture !== false) scorePrompt(cfg, payload); if (cfg.capture) maybeDrain(cfg, runId, payload); return { suppressOutput: true }; } diff --git a/integrations/codex/hooks/dist/impl/subagent-start.mjs b/integrations/codex/hooks/dist/impl/subagent-start.mjs index 5630fa2..8a10c62 100755 --- a/integrations/codex/hooks/dist/impl/subagent-start.mjs +++ b/integrations/codex/hooks/dist/impl/subagent-start.mjs @@ -370,6 +370,16 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -443,6 +453,8 @@ function resolveAll(e, userFile, creds, projectDir2, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -588,7 +600,7 @@ var init_config = __esm({ ]; CACHE_FILE = "config.json"; CACHE_TTL_MS = 300 * 1e3; - CACHE_VERSION = 3; + CACHE_VERSION = 4; MAX_ENV_TAGS = 8; MODE = "hosted"; LANG_FILES = [ @@ -1156,6 +1168,37 @@ var init_hook = __esm({ } }); +// ../claude-code/lib/handles.mjs +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function handleTag(ref) { + const h = handleFor(ref); + return h ? `[${h}]` : ""; +} +var ALPHABET, LEN, BODY, BARE_RE, TAG_RE; +var init_handles = __esm({ + "../claude-code/lib/handles.mjs"() { + ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + LEN = 4; + BODY = `[${ALPHABET}]{${LEN}}`; + BARE_RE = new RegExp(`^m${BODY}$`); + TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); + } +}); + // ../claude-code/lib/assemble.mjs function sectionFor(entryType) { const t = typeof entryType === "string" ? entryType.trim().toLowerCase() : ""; @@ -1180,12 +1223,14 @@ function assembleContext(evidence, opts = {}) { if (!isObject2(e)) continue; const text = oneLine(e.content); if (!text) continue; - const section = sectionFor(str2(e.origin_entry_type) || str2(e.entry_type)); + const type = (str2(e.origin_entry_type) || str2(e.entry_type)).toLowerCase(); + const section = sectionFor(type); if (allowed && !allowed.has(section)) continue; candidates++; const bucket = bySection.get(section) ?? []; bucket.push({ ref: str2(e.reference_id), + type, text, score: finite(e.score, 0), stale: e.is_stale === true, @@ -1201,12 +1246,14 @@ function assembleContext(evidence, opts = {}) { sourceRefIds: [], dropped: 0, pointers: 0, - emptyReason: "no_evidence" + emptyReason: "no_evidence", + entries: [] }; } const parts = []; const sourceRefIds = []; const seenRefs = /* @__PURE__ */ new Set(); + const entries = []; const sections = []; let used = 0; let rendered = 0; @@ -1224,9 +1271,10 @@ function assembleContext(evidence, opts = {}) { if (perSection > 0 && count >= perSection) { continue; } - const full = `- ${item.stale ? "(stale) " : ""}${item.text} + const tag = handleTag(item.ref); + const full = `- ${tag ? `${tag} ` : ""}${item.stale ? "(stale) " : ""}${item.text} `; - const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${item.ref} \u2014 ${firstClause(item.text)} + const pointer = seen && item.ref && seen.has(item.ref) ? `- ${POINTER_MARK} ${item.stale ? "(stale) " : ""}${tag} \u2014 ${firstClause(item.text)} ` : ""; const degraded = !!pointer && pointer.length < full.length; const line = degraded ? pointer : full; @@ -1245,6 +1293,18 @@ function assembleContext(evidence, opts = {}) { seenRefs.add(item.ref); sourceRefIds.push(item.ref); } + if (!item.ref || entries.every((x) => x.ref !== item.ref)) { + entries.push({ + ref: item.ref, + handle: handleFor(item.ref), + section: key, + type: item.type, + text: item.text, + title: firstClause(item.text), + pointer: degraded, + stale: item.stale + }); + } } if (count > 0) sections.push({ section: key, count }); } @@ -1260,7 +1320,8 @@ function assembleContext(evidence, opts = {}) { // A degraded entry is rendered, not dropped: it counts here and nowhere else, so a // reader can tell a block that shrank from a block that lost half its evidence. pointers, - emptyReason: rendered > 0 ? "" : "budget_exhausted" + emptyReason: rendered > 0 ? "" : "budget_exhausted", + entries: rendered > 0 ? entries : [] }; } function firstClause(text) { @@ -1305,6 +1366,7 @@ function positiveInt2(v, d) { var SECTION_KEYS, EMISSION_ORDER, RENDER_ORDER, SECTION_BY_ENTRY_TYPE, HEADINGS, DEFAULT_TOKEN_BUDGET, CHARS_PER_TOKEN, MAX_ITEM_CHARS, POINTER_MARK, MAX_POINTER_CHARS, MIN_POINTER_CHARS; var init_assemble = __esm({ "../claude-code/lib/assemble.mjs"() { + init_handles(); SECTION_KEYS = Object.freeze([ "mental_models", "active_rules", @@ -1639,7 +1701,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1721,7 +1783,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1812,13 +1874,13 @@ function requireString(req, field, who, hint) { } const v = req[field]; if (typeof v === "string" && v.trim()) return ""; - return `${who}: "${field}" is required and must be a non-empty string (\xA71.3 \u2014 a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); + return `${who}: "${field}" is required and must be a non-empty string (a missing field is a 422, not a default)` + (hint ? `; ${hint}` : ""); } function requireMode(req) { const mode = req && typeof req === "object" ? req.mode : void 0; if (typeof mode === "string" && QUERY_MODES.includes(mode)) return ""; const shown = mode === void 0 ? "(omitted)" : JSON.stringify(mode); - return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed server-side and costs an LLM call per prompt with no error.`; + return `postQuery: invalid query mode ${shown} \u2014 must be exactly one of ${QUERY_MODES.map((m) => `"${m}"`).join(", ")} (case-sensitive). Anything else \u2014 including an omitted mode \u2014 silently becomes agent_routed, which is slower on every prompt, with no error.`; } function safeMode(req) { const m = req && typeof req === "object" ? req.mode : void 0; @@ -2276,30 +2338,30 @@ async function ladder(cfg, o) { limit: QUERY_LIMIT, entry_types: [...ENTRY_TYPES], include_working_memory: true, - // `env_tags` is accepted on the query route but not on the context route — version-aware - // tag scoring is capability rungs 1-2 gain over rung 3, not something they give up. + // `env_tags` is accepted on the query route but not on the context route — a capability + // rungs 1-2 gain over rung 3, not something they give up. // Tagged from the directory this prompt was sent in, not the one the session launched - // in — the same reason the run id reads the payload. A recall scored against `repo:` - // tags from the wrong repo is worse than one scored against none. + // in — the same reason the run id reads the payload. A recall tagged with the wrong repo + // is worse than an untagged one. env_tags: envTags(cfg, o.projectDir), // `rank_by` is the same trap as `env_tags` above, one field further on. `/context` // accepts no ranking field of ANY kind, // which makes freshness the second capability rungs 1-2 gain over rung 3 rather than // something they give up. What makes it a trap rather than a limitation: turning rung 3 // on (`recallAssemble: "server"`) does not fail, warn, or fall back: it silently reverts - // every recall to the default fusion weights, and "where were we?" quietly goes back to + // every recall to the default ordering, and "where were we?" quietly goes back to // answering with whatever is most similar. Documented in the README's `recallAssemble` // row for the same reason. // // Omitted rather than sent when it resolves to nothing: absent IS `relevance` // server-side, so there is no shape of request this spread cannot express. ...rankBy ? { rank_by: rankBy } : {}, - // §5.2: opting out of the cross-run lesson overlay, and the ONLY field here that is sent - // to make the request cheaper rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. + // Opting out of cross-run lessons, and the ONLY field here that is sent to make the + // request faster rather than better. See `CROSS_RUN_MIN_BUDGET_MS`. // - // Omitted rather than sent as `false` when the overlay is wanted: absent IS `false` - // server-side, and a request log that only ever shows the field when somebody declined - // the lane is easier to read than one where every request carries it. + // Omitted rather than sent as `false` when they are wanted: absent IS `false`, and a + // request log that only ever shows the field when somebody declined them is easier to + // read than one where every request carries it. ...crossRun ? {} : { prefer_current_run: true } }; let denied = readPolicyDenial(cfg); @@ -2314,7 +2376,7 @@ async function ladder(cfg, o) { if (res2.status === 403) { cachePolicyDenial(cfg); if (cfg.recallFallback !== "agent_routed") { - log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to pay an LLM call per prompt instead.', { run_id: o.runId }); + log(cfg, "warn", 'prompt-recall: direct_bypass is disabled by instance policy and MUBIT_CC_RECALL_FALLBACK is "none", so this recall returns empty. Ask your operator to enable direct search, or set MUBIT_CC_RECALL_FALLBACK=agent_routed to accept a slower recall instead.', { run_id: o.runId }); return empty(1, "policy_denied"); } log( @@ -2379,7 +2441,8 @@ function fromContext(responseBody, rung) { dropped: numOr(b.evidence_dropped_by_budget, 0), pointers: 0, emptyReason: typeof b.empty_reason === "string" && b.empty_reason ? b.empty_reason : block ? "" : "no_evidence", - refIds + refIds, + entries: [] }; } function fromEvidence(cfg, responseBody, rung, o) { @@ -2401,9 +2464,10 @@ function fromEvidence(cfg, responseBody, rung, o) { dropped: a.dropped, pointers: a.pointers, emptyReason: a.emptyReason, - // §4.10/§5.5: a degraded entry is still in here. Dropping a repeat would break + // A degraded entry is still in here. Dropping a repeat would break // attribution for exactly the memories that are helping most. - refIds: a.sourceRefIds + refIds: a.sourceRefIds, + entries: a.entries }; } function tokenBudgetOf(cfg, o) { @@ -2431,7 +2495,8 @@ function empty(rung, reason) { dropped: 0, pointers: 0, emptyReason: reason, - refIds: [] + refIds: [], + entries: [] }; } function failure(state, error, rung) { @@ -2445,6 +2510,7 @@ function failure(state, error, rung) { pointers: 0, emptyReason: "", refIds: [], + entries: [], state: typeof state === "string" ? state : "server_error", error: typeof error === "string" ? error : String(error ?? "") }; @@ -2542,11 +2608,66 @@ var init_recall = __esm({ } }); +// ../claude-code/lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync3, + readFileSync as readFileSync6, + readSync, + statSync as statSync5, + writeSync as writeSync3 +} from "node:fs"; +import { dirname as dirname4, join as join10 } from "node:path"; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join10(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function appendScoreRow(cfg, sessionId, row) { + try { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.kind !== "string" || !row.kind) return false; + const p = scorecardPath(cfg, sessionId); + if (!p || !ensureDir(dirname4(p))) return false; + const line = `${JSON.stringify({ v: SCORE_LOG_VERSION, at: Date.now(), ...row })} +`; + const fd = openSync2(p, "a+"); + try { + const st = fstatSync(fd); + let prefix = ""; + if (st.size > 0) { + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, st.size - 1); + if (last[0] !== 10) prefix = "\n"; + } + writeSync3(fd, prefix + line); + } finally { + closeSync2(fd); + } + return true; + } catch { + return false; + } +} +var SCORE_LOG_VERSION, SCORE_DIR, SCORE_LOG_TTL_MS, MAX_READ_BYTES, MAX_ID; +var init_scorecard_log = __esm({ + "../claude-code/lib/scorecard-log.mjs"() { + init_state(); + SCORE_LOG_VERSION = 1; + SCORE_DIR = "scorecard"; + SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + MAX_READ_BYTES = 4 * 1024 * 1024; + MAX_ID = 128; + } +}); + // ../claude-code/lib/runid.mjs import { spawnSync as spawnSync2 } from "node:child_process"; import { createHash as createHash4 } from "node:crypto"; -import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync5 } from "node:fs"; -import { basename as basename2, dirname as dirname4, join as join10, resolve as resolve3 } from "node:path"; +import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync6 } from "node:fs"; +import { basename as basename2, dirname as dirname5, join as join11, resolve as resolve3 } from "node:path"; function agentRole(env = process.env) { const host2 = typeof env?.MUBIT_CC_HOST === "string" ? env.MUBIT_CC_HOST.trim().toLowerCase() : ""; return AGENT_ROLES[host2] ?? DEFAULT_AGENT_ROLE; @@ -2642,7 +2763,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -2742,7 +2863,7 @@ function normaliseRecord(record) { return out; } function sessionPath(file) { - return join10(dataDir({}), "sessions", `${file}.json`); + return join11(dataDir({}), "sessions", `${file}.json`); } function sessionFileName(sessionId) { const raw = typeof sessionId === "string" ? sessionId.trim() : ""; @@ -2763,7 +2884,7 @@ function usableDir(v) { const s = typeof v === "string" ? v.trim() : ""; if (!s) return ""; try { - return statSync5(s).isDirectory() ? s : ""; + return statSync6(s).isDirectory() ? s : ""; } catch { return ""; } @@ -2793,8 +2914,8 @@ function hasGitDir2(start) { try { let cur = resolve3(start); for (let i = 0; i < 24; i++) { - if (existsSync6(join10(cur, ".git"))) return true; - const up = dirname4(cur); + if (existsSync6(join11(cur, ".git"))) return true; + const up = dirname5(cur); if (up === cur) return false; cur = up; } @@ -2878,12 +2999,12 @@ var init_runid = __esm({ // ../claude-code/hooks/src/subagent-start.mjs var subagent_start_exports = {}; -import { join as join11 } from "node:path"; +import { join as join12 } from "node:path"; function parentQuery(cfg, runId, payload) { try { - const promptId = safeSegment(turnKey(payload), MAX_ID); + const promptId = safeSegment(turnKey(payload), MAX_ID2); if (!promptId) return ""; - const file = join11(runDir(cfg, runId), "turns", `${promptId}.json`); + const file = join12(runDir(cfg, runId), "turns", `${promptId}.json`); const turn = readJson(file, null); return isObject7(turn) ? str5(turn.prompt).slice(0, MAX_QUERY_CHARS) : ""; } catch { @@ -2892,10 +3013,10 @@ function parentQuery(cfg, runId, payload) { } function persistSubRun(cfg, o) { try { - const name = safeSegment(o.subRunId, MAX_ID); + const name = safeSegment(o.subRunId, MAX_ID2); if (!name) return; - const dir = join11(runDir(cfg, o.runId), "subagents"); - writeJsonAtomic(join11(dir, `${name}.json`), { + const dir = join12(runDir(cfg, o.runId), "subagents"); + writeJsonAtomic(join12(dir, `${name}.json`), { sub_run_id: o.subRunId, parent_run_id: o.runId, // The host's own id, unmodified — `mubit_agent_id` is what went on the wire. Both, @@ -2914,7 +3035,7 @@ function persistSubRun(cfg, o) { sources: (o.outcome?.refIds?.length ?? 0) || numOr2(o.outcome?.sources, 0), tokens: numOr2(o.outcome?.tokens, 0), // Characters are what was actually injected; the token figure is a - // four-chars-per-token estimate (§4.10) a later reader can re-derive from these. + // four-chars-per-token estimate a later reader can re-derive from these. chars: str5(o.outcome?.block).length, dropped: numOr2(o.outcome?.dropped, 0), // Always 0 while the seen-set stays out of this path. Recorded rather than assumed, @@ -3017,7 +3138,7 @@ function messageOf3(err) { return "unknown error"; } } -var OWN_AGENTS, MAX_QUERY_CHARS, MAX_ID, CFG, RECALL_BUDGET_MS, HARNESS_BUDGET_MS, SUPPRESS; +var OWN_AGENTS, MAX_QUERY_CHARS, MAX_ID2, CFG, RECALL_BUDGET_MS, HARNESS_BUDGET_MS, SUPPRESS; var init_subagent_start = __esm({ async "../claude-code/hooks/src/subagent-start.mjs"() { init_config(); @@ -3026,11 +3147,12 @@ var init_subagent_start = __esm({ init_pins(); init_rank(); init_recall(); + init_scorecard_log(); init_runid(); init_state(); OWN_AGENTS = /* @__PURE__ */ new Set(["mubit-recall", "mubit-memory:mubit-recall"]); MAX_QUERY_CHARS = 2e3; - MAX_ID = 128; + MAX_ID2 = 128; CFG = safeConfig2(); RECALL_BUDGET_MS = clampInt(CFG.recallBudgetMs, 1500, 50, 1e4); HARNESS_BUDGET_MS = Math.min(RECALL_BUDGET_MS + 400, 2800); @@ -3071,13 +3193,13 @@ var init_subagent_start = __esm({ } const outcome = await recallBlock(cfg, { runId, - // the PARENT run: nothing is stored under a sub-run id (§4.3). + // the PARENT run: nothing is stored under a sub-run id. agentId, // …but the subagent's own identity, so siblings are separable. query, deadline: deadline2, tokenBudget: CFG.subagentRecallTokenBudget, - // §5.2 — the same rule over the same query text the parent's own prompt gets. The + // The same rule over the same query text the parent's own prompt gets. The // staged parent turn is the only description of this subagent's task, so it is also // the only thing that can say the task is a handoff; a fan-out spawned off "where were // we?" wants the recency emphasis its parent turn got, or the parent is caught up and @@ -3101,6 +3223,16 @@ var init_subagent_start = __esm({ } persistSubRun(cfg, { runId, subRunId, agentId, payload, outcome, ms, pins }); if (!outcome.block) return pinsOnly(runId, agentId, pins); + if (cfg.capture !== false) { + try { + appendScoreRow(cfg, str5(payload?.session_id), { + kind: "refs", + source: "subagent", + refs: [...outcome.refIds] + }); + } catch { + } + } return { hookSpecificOutput: { hookEventName: "SubagentStart", diff --git a/integrations/codex/lib/boot.mjs b/integrations/codex/lib/boot.mjs index 93252ab..ca351e8 100644 --- a/integrations/codex/lib/boot.mjs +++ b/integrations/codex/lib/boot.mjs @@ -174,8 +174,8 @@ const DATA_DIR_PREFIX = 'mubit-memory'; * The consequence was silent and complete. A Codex session derived the *same run id* as the * Claude Code session in the same directory — the sharing worked — and then wrote it into * `…/mubit-memory` while Claude Code read `…/mubit-memory-mubit`. Two memories of one project, - * one of them missing the credentials, and nothing anywhere reporting it. Measured on a real - * install: both directories held a run named `cc-mubit-plugin-testing-41703b8c`. + * one of them missing the credentials, and nothing anywhere reporting it. Seen on a real + * install: both directories held a run of the same name. * * --------------------------------------------------------------------------- * The preference order, and why each rung diff --git a/integrations/codex/mcp/dist/index.js b/integrations/codex/mcp/dist/index.js index e47a20f..90465c8 100644 --- a/integrations/codex/mcp/dist/index.js +++ b/integrations/codex/mcp/dist/index.js @@ -182,7 +182,7 @@ var DEFAULT_MCP_TOOLS = [ ]; var CACHE_FILE = "config.json"; var CACHE_TTL_MS = 300 * 1e3; -var CACHE_VERSION = 3; +var CACHE_VERSION = 4; function screaming(key) { return String(key).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); } @@ -302,6 +302,16 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { ["off", "implicit", "explicit"], "implicit" ); + const sessionScore = enumOf( + pick("sessionScore", "MUBIT_CC_SESSION_SCORE"), + ["off", "compact", "full"], + host(e) === "codex" ? "off" : "full" + ); + const outcomeReview = enumOf( + pick("outcomeReview", "MUBIT_CC_OUTCOME_REVIEW"), + ["off", "nudge", "stop"], + host(e) === "codex" ? "nudge" : "stop" + ); const statusLine = bool(pick("statusLine", "MUBIT_CC_STATUSLINE"), host(e) !== "codex"); const preToolWarnings = bool(pick("preToolWarnings", "MUBIT_CC_PRE_TOOL_WARNINGS"), false); const resumeBlock = bool(pick("resumeBlock", "MUBIT_CC_RESUME_BLOCK"), true); @@ -375,6 +385,8 @@ function resolveAll(e, userFile, creds, projectDir, dataDir2) { resumeTokenBudget, policyTtlMs, outcomeMode, + sessionScore, + outcomeReview, reflectOnEnd, sessionEndDetach, statusLine, @@ -863,7 +875,7 @@ function assertUsableRunId(id) { const s = typeof id === "string" ? id.trim() : ""; if (!s || FORBIDDEN_RUN_IDS.has(s.toLowerCase())) { throw new Error( - `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run shared by every user and project on the machine (\xA74.3).` + `lib/runid.mjs refused to emit the run id ${JSON.stringify(id)}. An empty run id, a bare "cc-" prefix, or the literal "default" would write this project's memory into a run that names no project.` ); } return s; @@ -1045,8 +1057,8 @@ function safeCwd2() { } // ../claude-code/mcp/src/egress.mjs -import { readdirSync as readdirSync3, statSync as statSync5 } from "node:fs"; -import { join as join8 } from "node:path"; +import { readdirSync as readdirSync4, statSync as statSync6 } from "node:fs"; +import { join as join9 } from "node:path"; // ../claude-code/lib/breaker.mjs import { createHash as createHash3 } from "node:crypto"; @@ -1329,7 +1341,7 @@ async function request(cfg, method, path, body, opts = {}) { return refuse( cfg, started, - `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project (\xA74.3)`, + `refusing to send run_id "${POISONED_RUN_ID}" to ${verb} ${route} \u2014 it is the bundled server's placeholder and identifies no project`, { route, run_id: POISONED_RUN_ID } ); } @@ -1396,7 +1408,7 @@ async function dial(cfg, o) { try { const headers = { accept: o.parse === "text" ? "text/plain, */*" : "application/json", - // §1.2: `Authorization: Bearer ` on everything. With no key configured the header + // `Authorization: Bearer ` on everything. With no key configured the header // is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose. ...authHeaders(cfg) }; @@ -1846,8 +1858,8 @@ async function listActivity(cfg, params2 = {}, opts = {}) { return ok({ ...corrected, nextPageToken: res.data.nextPageToken, - // The server's count, over the server's filtering, before paging. It over-counts by - // `droppedDerived` whenever the re-filter had to do work. + // The response's own count. It over-counts by `droppedDerived` whenever the re-filter had + // to do work. totalVisible: res.data.totalVisible }); } @@ -2042,6 +2054,73 @@ function clamp2(v, lo, hi, dflt) { return Math.min(hi, Math.max(lo, Math.trunc(n))); } +// ../claude-code/lib/handles.mjs +var ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +var LEN = 4; +var BODY = `[${ALPHABET}]{${LEN}}`; +var BARE_RE = new RegExp(`^m${BODY}$`); +var TAG_RE = new RegExp(`\\[m${BODY}\\]`, "g"); +function handleFor(ref) { + const s = typeof ref === "string" ? ref.trim() : ""; + if (!s) return ""; + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + let out = "m"; + for (let i = 0; i < LEN; i++) { + out += ALPHABET[h % ALPHABET.length]; + h = Math.floor(h / ALPHABET.length); + } + return out; +} +function isHandle(v) { + return BARE_RE.test(bareOf(v)); +} +function resolveHandles(ids, knownRefs) { + const byHandle = /* @__PURE__ */ new Map(); + for (const ref of Array.isArray(knownRefs) ? knownRefs : []) { + const h = handleFor(ref); + if (h) byHandle.set(h, ref); + } + const out = []; + const unresolved = []; + for (const raw of Array.isArray(ids) ? ids : []) { + if (typeof raw !== "string") continue; + const bare = bareOf(raw); + if (BARE_RE.test(bare)) { + const ref = byHandle.get(bare); + if (ref) out.push(ref); + else { + out.push(bare); + unresolved.push(bare); + } + } else if (raw.trim()) { + out.push(raw.trim()); + } + } + return { ids: out, unresolved }; +} +function knownRefsFromRows(rows) { + const last = /* @__PURE__ */ new Map(); + let n = 0; + const note2 = (ref) => { + if (typeof ref === "string" && ref.trim()) last.set(ref.trim(), n++); + }; + for (const row of Array.isArray(rows) ? rows : []) { + if (!row || typeof row !== "object") continue; + if (row.kind !== "start" && row.kind !== "shown" && row.kind !== "refs") continue; + if (row.lessons && typeof row.lessons === "object") for (const ref of Object.keys(row.lessons)) note2(ref); + if (Array.isArray(row.refs)) for (const ref of row.refs) note2(ref); + } + return [...last.entries()].sort((a, b) => a[1] - b[1]).map(([ref]) => ref); +} +function bareOf(v) { + const s = typeof v === "string" ? v.trim() : ""; + return s.startsWith("[") && s.endsWith("]") ? s.slice(1, -1).trim() : s; +} + // ../claude-code/lib/markers.mjs import { join as join7 } from "node:path"; function defaultMarker(runId = "") { @@ -2051,7 +2130,7 @@ function defaultMarker(runId = "") { state: "unknown", updated_at: 0, cold_start_until: 0, - // `dry_streak` and `last_hit_at` are what make a permanently dead recall path visible. + // `dry_streak` and `last_hit_at` are what make a recall path that never returns visible. // Everything else here describes the *last* recall, which is exactly the wrong shape for // "recall has returned nothing for the last forty prompts": a run of total failures and a // healthy run that happened to draw a blank write identical rows. The streak is the only @@ -2124,11 +2203,97 @@ function updateMarker(cfg, runId, patch = {}) { } } +// ../claude-code/lib/scorecard-log.mjs +import { + closeSync as closeSync2, + fstatSync, + openSync as openSync2, + readdirSync as readdirSync3, + readFileSync as readFileSync5, + readSync, + statSync as statSync5, + writeSync as writeSync2 +} from "node:fs"; +import { dirname as dirname4, join as join8 } from "node:path"; +var SCORE_DIR = "scorecard"; +var SCORE_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1e3; +var MAX_READ_BYTES = 4 * 1024 * 1024; +var MAX_ID = 128; +function scorecardPath(cfg, sessionId) { + const id = safeSegment(typeof sessionId === "string" ? sessionId.trim() : "", MAX_ID); + if (!id) return ""; + return join8(resolveDataDir(cfg), SCORE_DIR, `${id}.jsonl`); +} +function readRowsAt(p, opts = {}) { + try { + const size = statSync5(p).size; + const tail = Number(opts?.tailBytes); + const want = Number.isFinite(tail) && tail > 0 ? Math.min(tail, MAX_READ_BYTES) : MAX_READ_BYTES; + let text; + let partialHead = false; + if (size > want) { + const fd = openSync2(p, "r"); + try { + const buf = Buffer.alloc(want); + readSync(fd, buf, 0, want, size - want); + text = buf.toString("utf8"); + } finally { + closeSync2(fd); + } + partialHead = true; + } else { + text = readFileSync5(p, "utf8"); + } + const lines = text.split("\n"); + if (partialHead) lines.shift(); + const out = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const row = JSON.parse(line); + if (row && typeof row === "object" && !Array.isArray(row) && typeof row.kind === "string") { + out.push(row); + } + } catch { + } + } + return out; + } catch { + return []; + } +} +function recentScoreLogs(cfg, opts = {}) { + try { + const dir = join8(resolveDataDir(cfg), SCORE_DIR); + const now = Date.now(); + const maxAge = Number(opts?.maxAgeMs) > 0 ? Number(opts.maxAgeMs) : SCORE_LOG_TTL_MS; + const limit = Number(opts?.limit) > 0 ? Math.trunc(Number(opts.limit)) : 20; + const found = []; + for (const name of readdirSync3(dir)) { + if (!name.endsWith(".jsonl")) continue; + const p = join8(dir, name); + try { + const st = statSync5(p); + if (st.isFile() && now - st.mtimeMs <= maxAge) found.push({ p, m: st.mtimeMs }); + } catch { + } + } + found.sort((a, b) => b.m - a.m); + return found.slice(0, limit).map((f) => f.p); + } catch { + return []; + } +} + // ../claude-code/mcp/src/egress.mjs var LATTICE = ["run", "session", "global", "org"]; var CEILINGS = ["run", "session", "global"]; var INGEST_PATH = "/v2/control/ingest"; var ARCHIVE_PATH = "/v2/control/archive"; +var OUTCOME_PATH = "/v2/control/outcome"; +var DEREFERENCE_PATH = "/v2/control/dereference"; +var HANDLES_NOTE_KEY = "mubit_handles"; +var HANDLES_HINT = "These ids match no memory line shown in this session and were sent as typed. Use the [m\u2026] id printed on a memory line, or a full reference_id."; var OPEN_TURN_GRACE_MS = 6e4; var TURN_FILES_TO_READ = 16; var LESSONS_PATH = "/v2/control/lessons"; @@ -2264,19 +2429,19 @@ function provenanceStamp(cfg, runId, sessionId, now = Date.now()) { return stamp; } function openTurn(cfg, runId, sessionId, now) { - const dir = join8(runDir(cfg, runId), "turns"); + const dir = join9(runDir(cfg, runId), "turns"); let names; try { - names = readdirSync3(dir).filter((f) => f.endsWith(".json")); + names = readdirSync4(dir).filter((f) => f.endsWith(".json")); } catch { return null; } if (names.length > TURN_FILES_TO_READ) { - names = names.map((f) => ({ f, at: mtimeOf(join8(dir, f)) })).sort((a, b) => b.at - a.at).slice(0, TURN_FILES_TO_READ).map((e) => e.f); + names = names.map((f) => ({ f, at: mtimeOf(join9(dir, f)) })).sort((a, b) => b.at - a.at).slice(0, TURN_FILES_TO_READ).map((e) => e.f); } let best = null; for (const f of names) { - const t = readJson(join8(dir, f), null); + const t = readJson(join9(dir, f), null); if (!isPlainObject3(t)) continue; if (String(t.session_id ?? "") !== sessionId) continue; if (!best || num2(t.started_at) > num2(best.started_at)) best = t; @@ -2290,7 +2455,7 @@ function openTurn(cfg, runId, sessionId, now) { } function mtimeOf(p) { try { - return statSync5(p).mtimeMs; + return statSync6(p).mtimeMs; } catch { return 0; } @@ -2428,6 +2593,72 @@ function recordMcpIngest(cfg, runId, items) { function countItems(body) { return Array.isArray(body?.items) ? body.items.length : 0; } +function resolveOutcomeBody(body, knownRefs) { + return resolveFields(body, knownRefs, true); +} +function resolveDereferenceBody(body, knownRefs) { + return resolveFields(body, knownRefs, false); +} +function knownRefsFor(cfg, sessionId, runId = "") { + try { + const c = cfg ?? {}; + const sid = sessionId || (runId ? latestSessionOf(c, runId) : ""); + const own = sid ? scorecardPath(c, sid) : ""; + const rows = []; + for (const p of recentScoreLogs(c).reverse()) if (p !== own) rows.push(...readRowsAt(p)); + if (own) rows.push(...readRowsAt(own)); + return knownRefsFromRows(rows); + } catch { + return []; + } +} +function latestSessionOf(cfg, runId) { + const dir = join9(runDir(cfg, runId), "turns"); + let names; + try { + names = readdirSync4(dir).filter((f) => f.endsWith(".json")); + } catch { + return ""; + } + const recent = names.map((f) => ({ f, at: mtimeOf(join9(dir, f)) })).sort((a, b) => b.at - a.at).slice(0, TURN_FILES_TO_READ); + let best = ""; + let bestAt = -1; + for (const { f } of recent) { + const t = readJson(join9(dir, f), null); + const sid = isPlainObject3(t) && typeof t.session_id === "string" ? t.session_id.trim() : ""; + if (sid && num2(t.started_at) > bestAt) { + best = sid; + bestAt = num2(t.started_at); + } + } + return best; +} +function carriesHandle(body, withEntries) { + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + if (isHandle(body.reference_id)) return true; + return withEntries && Array.isArray(body.entry_ids) && body.entry_ids.some((id) => isHandle(id)); +} +function resolveFields(body, knownRefs, withEntries) { + const noop = { body, changed: false, unresolved: ( + /** @type {string[]} */ + [] + ) }; + try { + if (!carriesHandle(body, withEntries)) return noop; + const unresolved = []; + const one = (id) => { + if (!isHandle(id)) return id; + const r = resolveHandles([id], knownRefs); + unresolved.push(...r.unresolved); + return r.ids[0] ?? id; + }; + const next = { ...body, reference_id: one(body.reference_id) }; + if (withEntries && Array.isArray(body.entry_ids)) next.entry_ids = body.entry_ids.map(one); + return { body: next, changed: true, unresolved }; + } catch { + return noop; + } +} function installFetchGuard(opts) { const ceiling = resolveCeiling(opts?.ceiling); const runId = typeof opts?.runId === "string" ? opts.runId : ""; @@ -2484,6 +2715,18 @@ function installFetchGuard(opts) { const out = stampProvenance(parsed.value, stampNow(), { at: "body" }); if (out.stamped) sendInit = { ...init, body: JSON.stringify(out.body) }; } + } else if (isPostTo(input, init, OUTCOME_PATH) || isPostTo(input, init, DEREFERENCE_PATH)) { + const parsed = parseBody(init); + const outcome = isPostTo(input, init, OUTCOME_PATH); + if (parsed.ok && carriesHandle(parsed.value, outcome)) { + const refs = knownRefsFor(opts?.cfg, sessionId, runId); + const out = outcome ? resolveOutcomeBody(parsed.value, refs) : resolveDereferenceBody(parsed.value, refs); + if (out.changed) sendInit = { ...init, body: JSON.stringify(out.body) }; + if (out.unresolved.length) { + note2 = { unresolved: out.unresolved, hint: HANDLES_HINT }; + noteKey = HANDLES_NOTE_KEY; + } + } } else if (isLessonsRead(input, init)) { const plan = await planLessons(init); if (plan.answer) return jsonResponse(plan.answer); @@ -2580,8 +2823,16 @@ var INSTRUCTIONS = [ "", "Which tool. mubit_recall for a topic or question in words. mubit_diagnose when a command or test has just failed, which matches the error shape against past failures. mubit_dereference when you already hold a reference_id. Reviewing the whole catalogue, the pattern across many lessons, a named checkpoint, deleting a lesson and an explicit reflect are skills (/mubit-memory:strategies, :checkpoint, :forget, :reflect), not tools.", "", - 'What to write back. mubit_learned records one durable claim \u2014 a constraint, a fix that worked, a standing preference \u2014 stated so it is still true in a later session. It is not a session log: narrating what happened ("the user asked for X", "I refactored Y") is the common way this tool is misused, and every future recall pays for it. mubit_outcome credits the reference_ids that actually helped, which is what makes the memory that helps rank higher next time.' + 'What to write back. mubit_learned records one durable claim \u2014 a constraint, a fix that worked, a standing preference \u2014 stated so it is still true in a later session. It is not a session log: narrating what happened ("the user asked for X", "I refactored Y") is the common way this tool is misused, and every future recall pays for it. Each injected memory line starts with an id in brackets, like [m7k2q]: pass those ids (or reference_ids) to mubit_outcome \u2014 outcome success for entries that helped, failure for ones that were wrong or misled you \u2014 which is what makes the memory that helps rank higher next time.' ].join("\n"); +var ALWAYS_LOAD_META = "anthropic/alwaysLoad"; +var ALWAYS_LOAD_TOOLS = Object.freeze(["mubit_outcome", "mubit_learned"]); +function alwaysLoadFor(cfg) { + const c = cfg && typeof cfg === "object" ? cfg : {}; + if (String(c.outcomeReview ?? "").trim().toLowerCase() === "off") return []; + if (c.host === "codex") return []; + return [...ALWAYS_LOAD_TOOLS]; +} function guardInitialize(message, instructions) { const noop = { message, changed: false }; try { @@ -2602,8 +2853,31 @@ function guardInitialize(message, instructions) { return noop; } } +function guardToolsList(message, names) { + const noop = { message, changed: false }; + try { + const wanted = new Set(Array.isArray(names) ? names : []); + if (!wanted.size) return noop; + if (!message || typeof message !== "object" || Array.isArray(message)) return noop; + if (message.jsonrpc !== "2.0") return noop; + const result = message.result; + if (!result || typeof result !== "object" || !Array.isArray(result.tools)) return noop; + let changed = false; + const tools = result.tools.map((t) => { + if (!t || typeof t !== "object" || !wanted.has(t.name)) return t; + const meta = t._meta && typeof t._meta === "object" && !Array.isArray(t._meta) ? t._meta : {}; + if (meta[ALWAYS_LOAD_META] === true) return t; + changed = true; + return { ...t, _meta: { ...meta, [ALWAYS_LOAD_META]: true } }; + }); + return changed ? { message: { ...message, result: { ...result, tools } }, changed } : noop; + } catch { + return noop; + } +} function installInstructionsGuard(opts) { const instructions = typeof opts?.instructions === "string" ? opts.instructions : ""; + const alwaysLoad = Array.isArray(opts?.alwaysLoad) ? opts.alwaysLoad.filter((n) => typeof n === "string" && n.trim()) : []; if (instructions.trim() === "") return; const stream = opts?.stream ?? process.stdout; const current = stream?.write; @@ -2621,6 +2895,13 @@ function installInstructionsGuard(opts) { } catch { } } + if (alwaysLoad.length && typeof chunk === "string" && chunk.includes('"tools":[')) { + try { + const marked = rewriteLines(chunk, (frame) => guardToolsList(frame, alwaysLoad)); + if (marked !== null) chunk = marked; + } catch { + } + } return base.call(this, chunk, ...rest); }; Object.defineProperty(wrapped, "mubitInstructionsGuardOriginal", { @@ -2629,11 +2910,14 @@ function installInstructionsGuard(opts) { configurable: true, enumerable: false }); - wrapped.mubitInstructionsGuard = { chars: instructions.length }; + wrapped.mubitInstructionsGuard = { chars: instructions.length, alwaysLoad }; stream.write = wrapped; } function fill(chunk, instructions) { if (!chunk.includes('"result"')) return null; + return rewriteLines(chunk, (frame) => guardInitialize(frame, instructions)); +} +function rewriteLines(chunk, guard) { const parts = chunk.split("\n"); let changed = false; for (let i = 0; i < parts.length; i += 1) { @@ -2644,7 +2928,7 @@ function fill(chunk, instructions) { } catch { continue; } - const out = guardInitialize(frame, instructions); + const out = guard(frame); if (!out.changed) continue; parts[i] = JSON.stringify(out.message); changed = true; @@ -2654,7 +2938,7 @@ function fill(chunk, instructions) { // ../claude-code/mcp/src/results.mjs import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs"; -import { join as join10 } from "node:path"; +import { join as join11 } from "node:path"; // ../claude-code/lib/assemble.mjs var SECTION_KEYS = Object.freeze([ @@ -2740,7 +3024,7 @@ function firstClause(text) { } // ../claude-code/lib/seen.mjs -import { join as join9 } from "node:path"; +import { join as join10 } from "node:path"; var SEEN_TTL_MS = 6 * 60 * 60 * 1e3; var MAX_SEEN_REFS = 512; var SEEN_DIR = "seen"; @@ -2752,7 +3036,7 @@ function seenPath(cfg, runId, sessionId) { if (!safeSegment(runId)) return ""; const session = safeSegment(hostSessionId({ session_id: sessionId }), MAX_SESSION_SEGMENT); if (!session) return ""; - return join9(runDir(cfg, runId), SEEN_DIR, `${session}.json`); + return join10(runDir(cfg, runId), SEEN_DIR, `${session}.json`); } function readSeen(cfg, runId, sessionId = "") { try { @@ -3071,10 +3355,10 @@ function spillWriter(cfg, runId) { return (text, shape) => { try { if (!safeSegment(runId)) return ""; - const dir = join10(runDir(cfg, runId), SPILL_DIR); + const dir = join11(runDir(cfg, runId), SPILL_DIR); mkdirSync3(dir, { recursive: true }); const ext = shape === "text" || shape === "error" ? "txt" : "json"; - const p = join10(dir, `${Date.now()}-${safeSegment(shape) || "result"}-${n++}.${ext}`); + const p = join11(dir, `${Date.now()}-${safeSegment(shape) || "result"}-${n++}.${ext}`); writeFileSync2(p, text, { encoding: "utf8", mode: 384 }); return p; } catch { @@ -3171,7 +3455,7 @@ function prepare(env) { const sessionId = hostPayload(env).session_id ?? ""; const ceiling = resolveCeiling(cfg.mcpLessonScope); installFetchGuard({ ceiling, runId, pinRun: true, cfg, sessionId }); - installInstructionsGuard({ instructions: INSTRUCTIONS }); + installInstructionsGuard({ instructions: INSTRUCTIONS, alwaysLoad: alwaysLoadFor(cfg) }); installResultsGuard({ cfg, runId, diff --git a/integrations/codex/skills/activity/SKILL.md b/integrations/codex/skills/activity/SKILL.md index dd69b4b..b22714f 100644 --- a/integrations/codex/skills/activity/SKILL.md +++ b/integrations/codex/skills/activity/SKILL.md @@ -55,7 +55,7 @@ The default is one page of the newest activity for the newest run in this data d `--all-runs` asks across every run the key can see. Filters: `--type` (repeatable), `--since` / `--until` as RFC3339, `--agent`, `--user`, and -`--exclude-derived` for the entries the instance promoted for itself rather than ones a client +`--exclude-derived` for the entries the instance derived itself rather than ones a client wrote. `--full` returns every field untruncated; the default is five keys per entry. Output: a table by default, `--jsonl` for one object per line, `--json` for one envelope. The @@ -79,8 +79,7 @@ reply** — the command prints it, and a file the user cannot find is a file the `--out` refuses to overwrite, and refuses any path inside the plugin data directory: an export there sits outside the TTL sweep, so it would live forever and nothing would ever mention it again. It warns when the destination is inside a git working tree that is not ignoring it. -`--all-runs` is refused with `--export`, because that route takes no limit and a run is the only -bound on how much comes back. +`--all-runs` cannot be combined with `--export`: an export covers one run. ## The data directory, which matters more here @@ -94,10 +93,9 @@ sure, or `--all-runs` to stop depending on the local state entirely. Three of the things this prints are **findings**, not decoration, and they are the reason the answer can be trusted. Relay them rather than summarising them away: -1. **"the instance did not honour exclude_derived"** means derived entries came back under a - request that excluded them, and this client dropped them locally. The listing is correct; the - server's filter is not. That is worth telling the user, because it is the difference between - a filter and a claim about a filter. +1. **"the instance did not honour exclude_derived"** means derived entries came back and this + client dropped them locally, so the listing is still correct. Say so, so the user knows the + filter was applied here. 2. **"the instance did not honour the compact projection"** means content was truncated here rather than upstream. Nothing was lost — `--full` or an export has all of it. 3. **"this answer is incomplete"** on a `--scan` means it stopped at a bound (entry cap, wall diff --git a/integrations/codex/skills/doctor/SKILL.md b/integrations/codex/skills/doctor/SKILL.md index 3d3a3a4..0956ce4 100644 --- a/integrations/codex/skills/doctor/SKILL.md +++ b/integrations/codex/skills/doctor/SKILL.md @@ -66,7 +66,7 @@ every other check below comes back clean while the model receives no memory at a `recall.empty_reason` for which kind: - `policy_denied` — the instance has direct-access recall (rung 1) disabled, and the - `agent_routed` fallback is off by default because it costs an LLM call per prompt. Ask the + `agent_routed` fallback is off by default because it adds seconds to every prompt. Ask the operator to enable direct search. `MUBIT_CC_RECALL_FALLBACK=agent_routed` restores recall at that cost; `MUBIT_CC_POLICY_TTL_MS=1` re-probes immediately once it is on. - `budget_exhausted` — recall ran out of time before the call returned. Raise diff --git a/integrations/codex/skills/forget/SKILL.md b/integrations/codex/skills/forget/SKILL.md index dc680c7..b36dd38 100644 --- a/integrations/codex/skills/forget/SKILL.md +++ b/integrations/codex/skills/forget/SKILL.md @@ -47,10 +47,9 @@ mcp__mubit__mubit_outcome reference_id= outcome=failure signal=-1 rationale="" ``` -The promotion pipeline acts on that. The lesson's confidence drops, it stops surfacing near -the top of recall, and — this is the part deletion cannot do — the *reason* it was wrong is -now part of the record. A deleted lesson teaches the system nothing. A down-weighted one -teaches it something, and the correction survives to shape what gets promoted next. +That makes the lesson less likely to be recalled, and — this is the part deletion cannot do — +the *reason* it was wrong is now part of the record. A deleted lesson teaches the system +nothing. A down-weighted one teaches it something, and the correction survives. ## When deletion is actually right diff --git a/integrations/codex/skills/handoff/SKILL.md b/integrations/codex/skills/handoff/SKILL.md index 022c8d3..38fe25d 100644 --- a/integrations/codex/skills/handoff/SKILL.md +++ b/integrations/codex/skills/handoff/SKILL.md @@ -55,8 +55,7 @@ node /bin/handoff.mjs list --open --json Without `--open`, every handoff in the run, each with the feedback that answered it. "Open" is computed by this command, not by the instance: a handoff is open when no feedback entry -names its id. The instance never flips a handoff's `active` flag, so do not read that field as -its state. +names its id. Do not read a handoff's `active` field as its state. ## Answer diff --git a/integrations/codex/skills/recall/SKILL.md b/integrations/codex/skills/recall/SKILL.md index 3ff6456..f80d2c3 100644 --- a/integrations/codex/skills/recall/SKILL.md +++ b/integrations/codex/skills/recall/SKILL.md @@ -12,9 +12,8 @@ When you do: 2. Read the evidence. If it answers the question, stop. 3. Only if the first call returned nothing usable, issue one reformulated call. -Never fan out into parallel searches across sub-topics. Mubit retrieval is hybrid -(semantic + lexical + recency + graph); one well-formed query beats four keyword slices at -a quarter of the latency. Two calls is the ceiling. +Never fan out into parallel searches across sub-topics: one well-formed query beats four +keyword slices at a quarter of the latency. Two calls is the ceiling. Cite what you use by its `reference_id`, and call `mcp__mubit__mubit_outcome` with those `entry_ids` when recalled memory turns out to be right or wrong. That feedback is what makes @@ -23,8 +22,8 @@ the next recall better. ## Writing the query Query with the *question*, not with keywords. "Why does the drain hook retry twice on a -5xx" retrieves better than "drain retry 5xx", because the semantic half of the hybrid index -has something to match on and the lexical half still catches the identifiers. Include the +5xx" retrieves better than "drain retry 5xx": a full question matches better, and the +identifiers inside it still count. Include the identifiers you already know — file names, symbol names, error strings — inside the sentence rather than instead of it. diff --git a/integrations/codex/skills/reflect/SKILL.md b/integrations/codex/skills/reflect/SKILL.md index 8bc9356..c53d448 100644 --- a/integrations/codex/skills/reflect/SKILL.md +++ b/integrations/codex/skills/reflect/SKILL.md @@ -56,17 +56,15 @@ already been shown is printed as its id and first clause, marked `(seen earlier) ## Why the explicit call exists at all -Mubit already extracts lessons on its own, in the background, as it ingests. It does that -perfectly well — and then stops there. Lessons extracted that way keep the scope they were -extracted at, typically `run`, and **a `run`-scoped lesson is invisible to the next session**. +A lesson drawn from a run's activity starts at `run` scope, and **a `run`-scoped lesson is +invisible to the next session**. (A `mcp__mubit__mubit_learned` write is different: it takes the +`mcpLessonScope` setting, `session` by default.) Lessons drawn from activity reach later +sessions only after an explicit reflect — this skill, or the one `SessionEnd` issues. Without +one, the store can look busy, lessons accumulating steadily, while nothing reaches a future +session. -The consequence is concrete: the store can look busy, lessons accumulating steadily, while -nothing ever crosses the boundary into a future session. Widening a lesson's scope is -reserved for the explicit reflect path — this skill, and the one `SessionEnd` issues. - -Widening is still gradual once a lesson is on that path. Rules are never scope-promoted, since -they are enforced as written; anything else has to establish itself before it travels. Expect -lessons to widen over several sessions, not on the first reflect. +Even then it is gradual: expect lessons to reach later sessions over several sessions, not on +the first reflect. ## When to invoke it @@ -74,11 +72,11 @@ lessons to widen over several sessions, not on the first reflect. That covers routine hygiene. Invoke this skill for a **mid-session checkpoint** — a long session that has just finished a real chunk of work, a debugging arc that ended in something worth keeping, or the point where the user is about to compact and wants the lessons banked -first. Do not call it every few turns: it is an LLM-backed extraction pass over the run, and -calling it on a run that has barely changed costs time and returns the same lessons. +first. Do not call it every few turns: it is slow, and calling it on a run that has barely +changed costs time and returns the same lessons. One timing detail worth knowing before you read a zero as a failure: reflection only sees -items the server has already **indexed**. A reflect fired immediately after a burst of +items that are already **searchable**. A reflect fired immediately after a burst of captures or an explicit `mubit-memory:remember` can honestly report `lessons_stored: 0` where the same run reflected about a minute later returns them. If you have just written something you expect to be reflected on, give ingest a moment rather than reflecting twice. diff --git a/integrations/codex/skills/remember/SKILL.md b/integrations/codex/skills/remember/SKILL.md index c6a2763..ba81a85 100644 --- a/integrations/codex/skills/remember/SKILL.md +++ b/integrations/codex/skills/remember/SKILL.md @@ -44,10 +44,10 @@ around it. `mcp__mubit__mubit_learned` returns when the write is **queued**, not stored. Do not immediately search for what you just saved; it will not be there yet. Ingest runs -asynchronously — the item is embedded and indexed after the call returns, so a search fired -in the same turn honestly returns nothing and that is not a sign that memory is broken. The -same applies to `mubit-memory:reflect`: reflection only sees items the server has already -indexed, so reflecting seconds after a write reports zero lessons where reflecting a minute +asynchronously — the item becomes searchable a little after the call returns, so a search +fired in the same turn honestly returns nothing and that is not a sign that memory is broken. +The same applies to `mubit-memory:reflect`: reflection only sees items that are already +searchable, so reflecting seconds after a write reports zero lessons where reflecting a minute later reports them. Note the one place the tool is narrower than the table: `mcp__mubit__mubit_learned` is the @@ -57,7 +57,6 @@ plugin clamps it there on purpose — anything wider is read back by unrelated p the run id is stable for a project: a lesson written here is recalled here tomorrow, **and it is recalled by a Claude Code session in the same directory too**, because both harnesses derive the same run. It simply stops following you into work it has nothing to do with. Use -the table to decide what the entry *is* and to word it accordingly, and let scope widening -happen the way it is supposed to — through the explicit reflect path, which is the only thing -that promotes a lesson beyond its own run. To raise the ceiling on what any MCP write may -claim, set `MUBIT_MCP_LESSON_SCOPE`. +the table to decide what the entry *is* and to word it accordingly, and leave wider reach to +the explicit reflect path, which is how lessons reach later sessions. To raise the ceiling on +what any MCP write may claim, set `MUBIT_MCP_LESSON_SCOPE`. diff --git a/integrations/codex/skills/setup/SKILL.md b/integrations/codex/skills/setup/SKILL.md index 16238fe..9bb77fc 100644 --- a/integrations/codex/skills/setup/SKILL.md +++ b/integrations/codex/skills/setup/SKILL.md @@ -169,9 +169,8 @@ Precedence, highest first: `MUBIT_ENDPOINT` / `MUBIT_API_KEY` in the environment `/credentials.json` (what `mubit-memory:auth` writes), then `/.mubit-cc.json`. -Codex has no plugin settings UI and no `CODEX_PLUGIN_OPTION_*` variables — the strings -`PLUGIN_OPTION` and `userConfig` appear nowhere in its binary — so those three rungs are the -whole ladder. Do not send a Codex user to a `/plugin` configure screen; it does not exist here. +Codex has no plugin settings UI and no `CODEX_PLUGIN_OPTION_*` variables, so those three +rungs are the whole ladder. Do not send a Codex user to a `/plugin` configure screen; it does not exist here. **If either value is missing, run `node /scripts/login.mjs`.** It prompts for a key without echoing it, checks it against the instance, and writes it to the directory Step 3