Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
]
}
45 changes: 34 additions & 11 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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):
#
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/scripts/leakcheck.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
51 changes: 51 additions & 0 deletions .github/scripts/leakcheck.selftest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 3 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Binary file modified docs/assets/claude-code-standing-rule-saved.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/codex-mubit-outcome-and-learned.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/dashboard-lessons-counters.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/dashboard-turn-injected-and-outcome.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions integrations/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,29 @@
"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",
"description": "How recalled memory is ranked: 'auto' reads it off the prompt \u2014 a handoff question like 'where were we?' is ranked by recency, everything else by similarity. 'relevance' is the default, and is what made those questions answer with the most similar memory rather than the most recent one; 'freshness' and 'balanced' pin the emphasis for every prompt. Costs nothing: it is one field on a request the plugin already sends. Ignored when recallAssemble is 'server', which accepts no ranking field at all." },
"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",
Expand Down
Loading
Loading