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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
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
4 changes: 4 additions & 0 deletions integrations/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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." },

Check warning on line 35 in integrations/claude-code/.claude-plugin/plugin.json

View workflow job for this annotation

GitHub Actions / deterministic scan

leakcheck: llm-cost-model

The number of model calls each retrieval path costs is our unit economics, per rung, in public. It also tells a reader which path to force in order to make a tenant expensive. — Describe latency the user can observe. A config table saying what a setting costs the user is fine — informed consent, and they are the ones paying for it. A comparison of the internal retrieval paths by call count is not, and that distinction is why this is a warning a human reads rather than a hard block.

Check warning on line 35 in integrations/claude-code/.claude-plugin/plugin.json

View workflow job for this annotation

GitHub Actions / deterministic scan

leakcheck: llm-cost-model

The number of model calls each retrieval path costs is our unit economics, per rung, in public. It also tells a reader which path to force in order to make a tenant expensive. — Describe latency the user can observe. A config table saying what a setting costs the user is fine — informed consent, and they are the ones paying for it. A comparison of the internal retrieval paths by call count is not, and that distinction is why this is a warning a human reads rather than a hard block.
"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",
Expand All @@ -45,6 +45,10 @@
"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." },
"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,
Expand Down
4 changes: 3 additions & 1 deletion integrations/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,9 @@ that cache, and writing credentials invalidates it immediately rather than after
| `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. |
| `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/<session_id>.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 `<mubit-resume>` 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. |
Expand Down
14 changes: 13 additions & 1 deletion integrations/claude-code/bin/activity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@
];
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();
}
Expand Down Expand Up @@ -544,6 +544,16 @@
["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);
Expand Down Expand Up @@ -617,6 +627,8 @@
resumeTokenBudget,
policyTtlMs,
outcomeMode,
sessionScore,
outcomeReview,
reflectOnEnd,
sessionEndDetach,
statusLine,
Expand Down Expand Up @@ -1093,7 +1105,7 @@
try {
const headers = {
accept: o.parse === "text" ? "text/plain, */*" : "application/json",
// §1.2: `Authorization: Bearer <key>` on everything. With no key configured the header

Check warning on line 1108 in integrations/claude-code/bin/activity.mjs

View workflow job for this annotation

GitHub Actions / deterministic scan

leakcheck: internal-spec-section

A bare section symbol in shipped source points at a document the reader does not have. Individually harmless; at scale it is a table of contents for the private specification, reconstructible by anyone who reads the tree. — Say the rule, not its address. In docs, a `§` that refers to a heading in the same file is fine — that is why this rule does not apply there.

Check warning on line 1108 in integrations/claude-code/bin/activity.mjs

View workflow job for this annotation

GitHub Actions / deterministic scan

leakcheck: internal-spec-section

A bare section symbol in shipped source points at a document the reader does not have. Individually harmless; at scale it is a table of contents for the private specification, reconstructible by anyone who reads the tree. — Say the rule, not its address. In docs, a `§` that refers to a heading in the same file is fine — that is why this rule does not apply there.
// is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose.
...authHeaders(cfg)
};
Expand Down
25 changes: 24 additions & 1 deletion integrations/claude-code/bin/admin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@
];
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();
}
Expand Down Expand Up @@ -549,6 +549,16 @@
["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);
Expand Down Expand Up @@ -622,6 +632,8 @@
resumeTokenBudget,
policyTtlMs,
outcomeMode,
sessionScore,
outcomeReview,
reflectOnEnd,
sessionEndDetach,
statusLine,
Expand Down Expand Up @@ -1104,7 +1116,7 @@
try {
const headers = {
accept: o.parse === "text" ? "text/plain, */*" : "application/json",
// §1.2: `Authorization: Bearer <key>` on everything. With no key configured the header

Check warning on line 1119 in integrations/claude-code/bin/admin.mjs

View workflow job for this annotation

GitHub Actions / deterministic scan

leakcheck: internal-spec-section

A bare section symbol in shipped source points at a document the reader does not have. Individually harmless; at scale it is a table of contents for the private specification, reconstructible by anyone who reads the tree. — Say the rule, not its address. In docs, a `§` that refers to a heading in the same file is fine — that is why this rule does not apply there.

Check warning on line 1119 in integrations/claude-code/bin/admin.mjs

View workflow job for this annotation

GitHub Actions / deterministic scan

leakcheck: internal-spec-section

A bare section symbol in shipped source points at a document the reader does not have. Individually harmless; at scale it is a table of contents for the private specification, reconstructible by anyone who reads the tree. — Say the rule, not its address. In docs, a `§` that refers to a heading in the same file is fine — that is why this rule does not apply there.
// is ABSENT rather than empty — `Bearer undefined` is a far harder 401 to diagnose.
...authHeaders(cfg)
};
Expand Down Expand Up @@ -1947,6 +1959,17 @@
return typeof v === "string" ? v.trim() : "";
}

// 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/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);
Expand Down
Loading
Loading