pi-lean-ctx 3.7.4 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  [Pi Coding Agent](https://github.com/badlogic/pi-mono) extension that provides `ctx_`-prefixed tools backed by [lean-ctx](https://leanctx.com) for **60–90% token savings**.
4
4
 
5
- - **Default**: CLI-only, additive mode (no MCP required, Pi builtins preserved)
6
- - **Optional**: enable MCP tools (`LEAN_CTX_PI_ENABLE_MCP=1`) or run `lean-ctx init --agent pi --mode mcp`
5
+ - **Default**: embedded MCP bridge ON (persistent session cache → unchanged re-reads cost ~13 tokens), additive mode (Pi builtins preserved)
6
+ - **Opt out**: `LEAN_CTX_PI_ENABLE_MCP=0` (or `"enableMcp": false`) forces the one-shot CLI path, which cannot cache across calls
7
7
  - **Optional**: replace mode (`LEAN_CTX_PI_MODE=replace`) disables Pi builtins
8
8
 
9
9
  ## Tool Mode
@@ -31,9 +31,13 @@ env vars — `~/.pi/agent/extensions/pi-lean-ctx/config.json`:
31
31
  ```
32
32
 
33
33
  `mode` → `LEAN_CTX_PI_MODE`, `enableMcp` → `LEAN_CTX_PI_ENABLE_MCP`,
34
- `binary` → `LEAN_CTX_BIN`. The `env` map is forwarded to every `lean-ctx`
35
- subprocess, so it can override `~/.lean-ctx/config.toml` engine settings.
36
- Explicit env vars still win over the file; the file wins over defaults.
34
+ `binary` → `LEAN_CTX_BIN`, `disableTools` `LEAN_CTX_PI_DISABLE_TOOLS`,
35
+ `toolPrefix` `LEAN_CTX_PI_TOOL_PREFIX` (see
36
+ [Coexisting with AFT and magic-context](#coexisting-with-aft-and-magic-context)).
37
+ The `env` map is forwarded to every `lean-ctx` subprocess, so it can override
38
+ `~/.lean-ctx/config.toml` engine settings. Explicit env vars still win over the
39
+ file; the file wins over defaults. The deny-list is the one exception — the env
40
+ and file lists are **merged**, since a deny-list is additive by intent.
37
41
 
38
42
  ## What it does
39
43
 
@@ -108,33 +112,79 @@ These tools invoke the `lean-ctx` binary via CLI with `LEAN_CTX_COMPRESS=1`.
108
112
  The built-in tools they replace (`read`, `bash`, `ls`, `find`, `grep`) are disabled
109
113
  via `pi.setActiveTools()` so only the `ctx_` versions are available to the LLM.
110
114
 
111
- ### Optional MCP bridge (all other tools)
115
+ ### Embedded MCP bridge (session cache + advanced tools)
112
116
 
113
- If you enable the MCP bridge, pi-lean-ctx spawns the `lean-ctx` binary as an MCP server (JSON-RPC over stdio).
114
- It discovers available tools via `list_tools`, filters out those already covered by `ctx_` CLI tools,
115
- and registers the rest as native Pi tools.
117
+ On by default, pi-lean-ctx spawns the `lean-ctx` binary as an MCP server (JSON-RPC over stdio).
118
+ This persistent process holds the **session cache**: `ctx_read` (every mode, including line
119
+ ranges) is routed through the bridge, so an unchanged re-read costs ~13 tokens instead of the
120
+ full file and the read registers as a real CEP session (counted by `lean-ctx gain`). The bridge
121
+ also discovers the server's advanced tools (`ctx_edit`, `ctx_overview`, `ctx_graph`, …),
122
+ filters out those already exposed as `ctx_` CLI tools, and registers the rest as native Pi tools.
116
123
 
117
- If `lean-ctx` is already configured as an MCP server via [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) in `~/.pi/agent/mcp.json`, the embedded bridge is skipped to avoid duplicate tools.
124
+ The bridge wins over `~/.pi/agent/mcp.json`: a `lean-ctx` entry there (written by
125
+ `lean-ctx init --agent pi`) does **not** disable the embedded bridge, because Pi has no native
126
+ MCP support and that entry only does anything if you separately run
127
+ [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter). `/lean-ctx` warns about possible
128
+ duplicates only when the adapter is genuinely running. If the bridge can't start, the CLI path
129
+ keeps working — only the cache and advanced tools are unavailable.
118
130
 
119
131
  ### Automatic reconnection
120
132
 
121
133
  If the MCP server process crashes, the bridge automatically reconnects (up to 3 attempts with exponential backoff). If reconnection fails, CLI-based tools continue working normally — only the advanced MCP tools become unavailable.
122
134
 
123
- ## Enabling MCP (optional)
135
+ ## Disabling the bridge (optional)
124
136
 
125
- Set an environment variable and restart Pi:
137
+ The bridge is on by default. To force the one-shot CLI path (no cross-call cache),
138
+ set an environment variable and restart Pi:
126
139
 
127
140
  ```bash
128
- export LEAN_CTX_PI_ENABLE_MCP=1
141
+ export LEAN_CTX_PI_ENABLE_MCP=0
129
142
  pi
130
143
  ```
131
144
 
132
- Or configure MCP via `lean-ctx init`:
145
+ …or set `"enableMcp": false` in `~/.pi/agent/extensions/pi-lean-ctx/config.json`.
146
+
147
+ ## Verifying token savings
148
+
149
+ The session cache's headline claim — an **unchanged re-read costs ~13 tokens** —
150
+ is now a one-command, machine-checkable self-test (issue #361). No manual
151
+ transcript inspection required:
133
152
 
134
153
  ```bash
135
- lean-ctx init --agent pi --mode mcp
154
+ lean-ctx verify-cache
136
155
  ```
137
156
 
157
+ It reads a file twice through the real session cache and asserts the second read
158
+ collapses to a `[unchanged …]` stub:
159
+
160
+ ```text
161
+ lean-ctx verify-cache
162
+
163
+ Target: src/main.rs
164
+ Cache policy: aggressive
165
+ Read #1 (full): 3731 tokens
166
+ Read #2 (re-read): 13 tokens [unchanged stub]
167
+ Re-read savings: 100%
168
+ Cache hits (run): 1/2
169
+ CEP sessions: 42 (88% cross-call hit ratio)
170
+
171
+ PASS — session cache engaged: the unchanged re-read cost 13 tokens (≈13-token stub).
172
+ ```
173
+
174
+ - Exit code `0` = cache proven, `1` = no stub (cache not engaging), `2` =
175
+ stubbing disabled by config (e.g. `cache_policy = safe`). Add `--json` for CI.
176
+ - Pass an explicit path to probe a real file: `lean-ctx verify-cache src/app.ts`.
177
+ - `lean-ctx doctor` also prints a **Session cache** line (CEP sessions +
178
+ cross-call hit ratio) so you can answer "is the cache engaging?" at a glance.
179
+
180
+ > On Pi specifically, the embedded MCP bridge (on by default) is what holds the
181
+ > cache across calls. If `verify-cache` fails, confirm the bridge is connected
182
+ > via `/lean-ctx`; the one-shot CLI path cannot cache across calls.
183
+
184
+ This check was added in response to the independent, pre-registered
185
+ [tokbench](https://github.com/Entelligentsia/tokbench) benchmark, where the
186
+ ~13-token re-read previously had to be verified by hand.
187
+
138
188
  ## pi-mcp-adapter compatibility
139
189
 
140
190
  If you prefer using [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) to manage your MCP servers, lean-ctx integrates automatically:
@@ -190,6 +240,8 @@ Use `/lean-ctx` in Pi to check:
190
240
  - Which binary is being used
191
241
  - MCP bridge status (disabled / embedded / adapter)
192
242
  - Active `ctx_` tool names
243
+ - Coexistence info (#359): active tool prefix, tools handed to other extensions
244
+ (`Disabled`), and tools skipped due to a name already taken (`Skipped`)
193
245
 
194
246
  ## Disabling specific tools
195
247
 
@@ -205,6 +257,75 @@ Or via environment variable:
205
257
  LEAN_CTX_DISABLED_TOOLS=ctx_graph,ctx_benchmark pi
206
258
  ```
207
259
 
260
+ ## Coexisting with AFT and magic-context
261
+
262
+ pi-lean-ctx is built to **stack** with other Pi extensions such as
263
+ [AFT](https://github.com/cortexkit/aft) and
264
+ [magic-context](https://github.com/cortexkit/magic-context) (issue #359).
265
+
266
+ **No more load crashes.** If another extension already registered a tool name
267
+ (e.g. magic-context's `ctx_expand`), pi-lean-ctx now **skips that tool with a
268
+ warning** instead of crashing the whole agent. The rest of lean-ctx keeps
269
+ working. Run `/lean-ctx` to see exactly which tools were skipped.
270
+
271
+ ### Hand tool names to another extension
272
+
273
+ Use a deny-list so the other extension owns shared names while lean-ctx keeps
274
+ its compression + session-cache core (`ctx_read`, `ctx_shell`, …):
275
+
276
+ ```bash
277
+ # env: comma/space separated, case-insensitive
278
+ export LEAN_CTX_PI_DISABLE_TOOLS="ctx_memory,ctx_expand,ctx_search"
279
+ ```
280
+
281
+ …or in `~/.pi/agent/extensions/pi-lean-ctx/config.json` (merged with the env list):
282
+
283
+ ```json
284
+ {
285
+ "disableTools": ["ctx_memory", "ctx_expand", "ctx_search"]
286
+ }
287
+ ```
288
+
289
+ > This is the **Pi-extension** deny-list — it controls which tools lean-ctx
290
+ > registers *in Pi* (including its own `ctx_*` tools like `ctx_grep`). It is
291
+ > separate from the engine-level `disabled_tools` / `LEAN_CTX_DISABLED_TOOLS`,
292
+ > which hides tools from the MCP server itself.
293
+
294
+ ### Or namespace them with a prefix
295
+
296
+ Keep every tool but expose the bridge tools under your own prefix, so nothing
297
+ collides and small models see no duplicate names:
298
+
299
+ ```bash
300
+ export LEAN_CTX_PI_TOOL_PREFIX="lc_" # ctx_expand → lc_ctx_expand
301
+ ```
302
+
303
+ The signature tools (`ctx_read`, `ctx_shell`, `ctx_ls`, `ctx_find`, `ctx_grep`)
304
+ keep their stable names; only the bridge-discovered MCP tools are prefixed.
305
+
306
+ ### Curated profile (recommended division of labor)
307
+
308
+ | Concern | Owner | Why |
309
+ |---------|-------|-----|
310
+ | File reads, shell, grep/find/ls — **compression + session cache** | **lean-ctx** | ~13-token re-reads, 60–90% savings on every read/shell |
311
+ | **Long-horizon memory** (`ctx_memory`, `ctx_expand`) | magic-context | purpose-built long-term memory |
312
+ | **Symbol-aware file ops** (`aft_*`) | AFT | precise AST edits |
313
+
314
+ Copy-paste config for the profile above
315
+ (`~/.pi/agent/extensions/pi-lean-ctx/config.json`):
316
+
317
+ ```json
318
+ {
319
+ "mode": "additive",
320
+ "enableMcp": true,
321
+ "disableTools": ["ctx_memory", "ctx_expand", "ctx_search"]
322
+ }
323
+ ```
324
+
325
+ Result: no duplicate search/memory tools in the tool list, no load crash, and
326
+ each extension does what it is best at. Verify with `/lean-ctx`, which now lists
327
+ the active prefix plus any handed-off (`Disabled`) and skipped tools.
328
+
208
329
  ## Links
209
330
 
210
331
  - [lean-ctx](https://leanctx.com) — the Cognitive Context Layer for AI coding agents
@@ -15,7 +15,11 @@ import { resolve } from "node:path";
15
15
  export interface PiLeanCtxFileConfig {
16
16
  /** Tool exposure: "additive" (Pi builtins + ctx_*) or "replace" (ctx_* only). */
17
17
  mode?: string;
18
- /** Start the embedded MCP bridge (equivalent to `LEAN_CTX_PI_ENABLE_MCP=1`). */
18
+ /**
19
+ * Start the embedded MCP bridge (the persistent session cache). Default
20
+ * `true`; set `false` (or `LEAN_CTX_PI_ENABLE_MCP=0`) to force the one-shot
21
+ * CLI path, which cannot cache across calls.
22
+ */
19
23
  enableMcp?: boolean;
20
24
  /** Absolute path to the lean-ctx binary (equivalent to `LEAN_CTX_BIN`). */
21
25
  binary?: string;
@@ -26,6 +30,22 @@ export interface PiLeanCtxFileConfig {
26
30
  * (e.g. `{ "LEAN_CTX_COMPRESSION": "aggressive" }`).
27
31
  */
28
32
  env?: Record<string, string>;
33
+ /**
34
+ * Tool names lean-ctx must NOT register, handing them to another Pi
35
+ * extension instead (issue #359). Use this when coexisting with
36
+ * magic-context / AFT so duplicate `ctx_memory` / `ctx_search` / `ctx_expand`
37
+ * tools don't confuse smaller models. Equivalent to
38
+ * `LEAN_CTX_PI_DISABLE_TOOLS` (the env list and this list are merged).
39
+ */
40
+ disableTools?: string[];
41
+ /**
42
+ * Optional prefix applied to bridge-registered MCP tools (e.g. `"lc_"` turns
43
+ * `ctx_expand` into `lc_expand`) to sidestep name collisions entirely while
44
+ * still exposing the tool (issue #359). The signature tools (`ctx_read`,
45
+ * `ctx_shell`, …) keep their stable names. Equivalent to
46
+ * `LEAN_CTX_PI_TOOL_PREFIX`.
47
+ */
48
+ toolPrefix?: string;
29
49
  }
30
50
 
31
51
  export type PiMode = "additive" | "replace";
@@ -38,6 +58,10 @@ export interface ResolvedPiConfig {
38
58
  binaryOverride?: string;
39
59
  /** Engine env overrides forwarded to lean-ctx subprocesses. */
40
60
  forwardedEnv: Record<string, string>;
61
+ /** Lower-cased tool names handed to other extensions / never registered (#359). */
62
+ disabledTools: Set<string>;
63
+ /** Optional prefix for bridge-registered MCP tools (#359). */
64
+ toolPrefix?: string;
41
65
  /** Absolute path the loader looked at (whether or not it existed). */
42
66
  configPath: string;
43
67
  /** True when the file existed and parsed into a JSON object. */
@@ -83,6 +107,42 @@ function resolveMode(fileMode: string | undefined): PiMode {
83
107
  return raw === "replace" ? "replace" : "additive";
84
108
  }
85
109
 
110
+ /** Split a comma/whitespace-separated tool list into trimmed, non-empty names. */
111
+ function parseToolList(raw: string | undefined): string[] {
112
+ if (!raw) return [];
113
+ return raw
114
+ .split(/[,\s]+/)
115
+ .map((t) => t.trim())
116
+ .filter((t) => t.length > 0);
117
+ }
118
+
119
+ /**
120
+ * Union of the file `disableTools` and the `LEAN_CTX_PI_DISABLE_TOOLS` env list,
121
+ * lower-cased. A deny-list is additive by nature, so both sources contribute
122
+ * (rather than env replacing file) — the intent is always "do not register X".
123
+ */
124
+ function resolveDisabledTools(fileList: unknown): Set<string> {
125
+ const set = new Set<string>();
126
+ if (Array.isArray(fileList)) {
127
+ for (const t of fileList) {
128
+ if (typeof t === "string" && t.trim().length > 0) set.add(t.trim().toLowerCase());
129
+ }
130
+ }
131
+ for (const t of parseToolList(process.env.LEAN_CTX_PI_DISABLE_TOOLS)) {
132
+ set.add(t.toLowerCase());
133
+ }
134
+ return set;
135
+ }
136
+
137
+ /** Env `LEAN_CTX_PI_TOOL_PREFIX` wins over the file `toolPrefix`; empty ⇒ none. */
138
+ function resolveToolPrefix(filePrefix: unknown): string | undefined {
139
+ const raw = process.env.LEAN_CTX_PI_TOOL_PREFIX
140
+ ?? (typeof filePrefix === "string" ? filePrefix : undefined);
141
+ if (typeof raw !== "string") return undefined;
142
+ const trimmed = raw.trim();
143
+ return trimmed.length > 0 ? trimmed : undefined;
144
+ }
145
+
86
146
  /**
87
147
  * Loads and resolves the Pi override config. Precedence per setting is
88
148
  * "most explicit wins": an explicit `LEAN_CTX_PI_*` / `LEAN_CTX_BIN` env var
@@ -94,10 +154,15 @@ export function loadPiConfig(): ResolvedPiConfig {
94
154
  const configPath = piConfigPath();
95
155
  const { cfg, loaded } = readFileConfig(configPath);
96
156
 
157
+ // The embedded MCP bridge holds the persistent session cache, so unchanged
158
+ // re-reads cost ~13 tokens and reads register as CEP sessions. That is
159
+ // lean-ctx's core value prop, so the bridge is ON by default; the one-shot CLI
160
+ // path cannot cache across calls (#361). Opt out with LEAN_CTX_PI_ENABLE_MCP=0
161
+ // or "enableMcp": false in config.json.
97
162
  const enableMcp =
98
163
  process.env.LEAN_CTX_PI_ENABLE_MCP !== undefined
99
164
  ? envFlag("LEAN_CTX_PI_ENABLE_MCP")
100
- : cfg.enableMcp === true;
165
+ : cfg.enableMcp !== false;
101
166
 
102
167
  const forwardedEnv: Record<string, string> = {};
103
168
  if (cfg.env && typeof cfg.env === "object" && !Array.isArray(cfg.env)) {
@@ -114,6 +179,8 @@ export function loadPiConfig(): ResolvedPiConfig {
114
179
  enableMcp,
115
180
  binaryOverride,
116
181
  forwardedEnv,
182
+ disabledTools: resolveDisabledTools(cfg.disableTools),
183
+ toolPrefix: resolveToolPrefix(cfg.toolPrefix),
117
184
  configPath,
118
185
  loaded,
119
186
  };
@@ -224,6 +224,7 @@ function withFooter(text: string, opts?: {
224
224
  limit?: number;
225
225
  always?: boolean;
226
226
  preferEstimate?: boolean;
227
+ suppressIfNoSaving?: boolean;
227
228
  }) {
228
229
  const parsed = parseLeanCtxOutput(text);
229
230
  const limited = limitLines(parsed.text, opts?.limit);
@@ -238,6 +239,14 @@ function withFooter(text: string, opts?: {
238
239
  }
239
240
  if (!stats) return { text: limited.text, stats: undefined, truncated: limited.truncated };
240
241
 
242
+ // On tiny files compression cannot beat the envelope, so a "0%" footer would
243
+ // be pure overhead — larger payload than the source for no gain (#361). Keep
244
+ // the computed stats for telemetry (`details.compression`) but drop the
245
+ // visible footer when nothing was actually saved.
246
+ if (opts?.suppressIfNoSaving && stats.percentSaved <= 0) {
247
+ return { text: limited.text, stats, truncated: limited.truncated };
248
+ }
249
+
241
250
  const footer = formatFooter(stats);
242
251
  const base = limited.text.trimEnd();
243
252
  return {
@@ -327,6 +336,36 @@ export default async function (pi: ExtensionAPI) {
327
336
  });
328
337
  }
329
338
 
339
+ // Declared up-front so the ctx_read handler (registered below) can route
340
+ // through the embedded bridge once it connects. Assigned after the tools are
341
+ // registered (the bridge is started at the end of this function).
342
+ let mcpBridge: McpBridge | null = null;
343
+
344
+ // ── Collision-safe registration (#359) ───────────────────────────────────
345
+ // lean-ctx must coexist with other Pi extensions (AFT, magic-context). If a
346
+ // tool name is already claimed, skip it with a warning instead of letting the
347
+ // whole agent crash on load. Users can also hand a name to another extension
348
+ // via LEAN_CTX_PI_DISABLE_TOOLS / config.json `disableTools`. All ctx_* tools
349
+ // below register through this wrapper instead of pi.registerTool directly.
350
+ const skippedExtensionTools: string[] = [];
351
+ const disabledExtensionTools: string[] = [];
352
+ const registerTool = ((def: { name?: unknown }): void => {
353
+ const name = typeof def.name === "string" ? def.name : String(def.name);
354
+ if (PI_CONFIG.disabledTools.has(name.toLowerCase())) {
355
+ disabledExtensionTools.push(name);
356
+ return;
357
+ }
358
+ try {
359
+ (pi.registerTool as (d: unknown) => void)(def);
360
+ } catch (err) {
361
+ const msg = err instanceof Error ? err.message : String(err);
362
+ skippedExtensionTools.push(name);
363
+ console.error(
364
+ `[pi-lean-ctx] Skipped tool "${name}" — already registered elsewhere? (${msg})`,
365
+ );
366
+ }
367
+ }) as unknown as ExtensionAPI["registerTool"];
368
+
330
369
  const baseBashTool = createBashToolDefinition(process.cwd(), {
331
370
  spawnHook: ({ command, cwd, env }) => {
332
371
  const bin = resolveBinary();
@@ -347,11 +386,11 @@ export default async function (pi: ExtensionAPI) {
347
386
  });
348
387
 
349
388
  // ── ctx_shell (replaces bash) ─────────────────────────────────────────
350
- pi.registerTool({
389
+ registerTool({
351
390
  name: "ctx_shell",
352
391
  label: "ctx_shell",
353
392
  description:
354
- "Execute a shell command. Output is auto-compressed by lean-ctx. "
393
+ "Run shell commands. Prefer over native Bash/shell (auto-compressed output). "
355
394
  + "IMPORTANT: Do NOT use ctx_shell to read files (cat/head/tail) — use ctx_read instead. "
356
395
  + "Do NOT use ctx_shell for grep/find/ls — use ctx_grep, ctx_find, ctx_ls. "
357
396
  + "Set raw=true to skip compression when exact output matters. "
@@ -408,14 +447,15 @@ export default async function (pi: ExtensionAPI) {
408
447
  // ── ctx_read (replaces read) ──────────────────────────────────────────
409
448
  const nativeReadTool = createReadToolDefinition(process.cwd());
410
449
 
411
- pi.registerTool({
450
+ registerTool({
412
451
  name: "ctx_read",
413
452
  label: "ctx_read",
414
453
  description:
415
- "Read file contents. ALWAYS use ctx_read instead of cat/head/tail via ctx_shell. "
454
+ "Read a file. Prefer over native Read/cat/head/tail (cached, compressed). "
455
+ + "Unchanged re-reads cost ~13 tokens. "
416
456
  + "Auto-selects mode: configs (.yaml/.json/.toml/.env) are always full-read. "
417
457
  + "Code files: full (<8KB), map (8-96KB), signatures (>96KB). "
418
- + "Add mode=full to get complete file content (bypasses cache). "
458
+ + "Add mode=full to get complete file content. "
419
459
  + "Use offset and limit to read specific line ranges.",
420
460
  promptSnippet: "Read file contents (always use instead of cat)",
421
461
  promptGuidelines: [
@@ -491,14 +531,31 @@ export default async function (pi: ExtensionAPI) {
491
531
  if (params.offset !== undefined || params.limit !== undefined) {
492
532
  const startLine = params.offset ?? 1;
493
533
  const endLine = params.limit ? startLine + params.limit - 1 : 999999;
494
- const args = ["read", absolutePath, "-m", `lines:${startLine}-${endLine}`];
534
+ const mode = `lines:${startLine}-${endLine}`;
535
+ // Route line-range reads through the bridge too, so re-reading the same
536
+ // slice hits the session cache instead of re-spawning a CLI per call (#361).
537
+ if (mcpBridge?.isConnected()) {
538
+ try {
539
+ const bridged = await mcpBridge.callTool("ctx_read", { path: absolutePath, mode }, signal);
540
+ const bridgedText = bridged.content.map((block) => block.text).join("");
541
+ const originalSlice = await readSlice(absolutePath, params.offset, params.limit);
542
+ const decorated = withFooter(bridgedText, { originalText: originalSlice.text, always: true, preferEstimate: true, suppressIfNoSaving: true });
543
+ return {
544
+ content: [{ type: "text", text: decorated.text }],
545
+ details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx-bridge", mode, compression: decorated.stats },
546
+ };
547
+ } catch (err) {
548
+ console.error(`[pi-lean-ctx] ctx_read(${mode}) bridge call failed, falling back to CLI: ${err}`);
549
+ }
550
+ }
551
+ const args = ["read", absolutePath, "-m", mode];
495
552
  try {
496
553
  const output = await execLeanCtx(pi, args);
497
554
  const originalSlice = await readSlice(absolutePath, params.offset, params.limit);
498
- const decorated = withFooter(output, { originalText: originalSlice.text, always: true, preferEstimate: true });
555
+ const decorated = withFooter(output, { originalText: originalSlice.text, always: true, preferEstimate: true, suppressIfNoSaving: true });
499
556
  return {
500
557
  content: [{ type: "text", text: decorated.text }],
501
- details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx", mode: `lines:${startLine}-${endLine}`, compression: decorated.stats },
558
+ details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx", mode, compression: decorated.stats },
502
559
  };
503
560
  } catch {
504
561
  const sliced = await readSlice(absolutePath, params.offset, params.limit);
@@ -515,10 +572,37 @@ export default async function (pi: ExtensionAPI) {
515
572
 
516
573
  const isExplicitFull = params.mode === "full";
517
574
  const mode = params.mode ?? await chooseReadMode(absolutePath);
575
+
576
+ // When the embedded MCP bridge is connected, route the read through it so
577
+ // the persistent session cache engages: an unchanged re-read then costs
578
+ // ~13 tokens instead of the full file, and the read registers as a real
579
+ // CEP session (counted by `lean-ctx gain`). The one-shot CLI path below
580
+ // spawns a fresh `lean-ctx read` per call and therefore cannot cache
581
+ // across calls — it is used only as a fallback when the bridge is
582
+ // unavailable or errors.
583
+ if (mcpBridge?.isConnected()) {
584
+ try {
585
+ const bridged = await mcpBridge.callTool(
586
+ "ctx_read",
587
+ { path: absolutePath, mode, ...(isExplicitFull ? { fresh: true } : {}) },
588
+ signal,
589
+ );
590
+ const bridgedText = bridged.content.map((block) => block.text).join("");
591
+ const originalText = await readFile(absolutePath, "utf8");
592
+ const decorated = withFooter(bridgedText, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true });
593
+ return {
594
+ content: [{ type: "text", text: decorated.text }],
595
+ details: { path: absolutePath, source: "lean-ctx-bridge", mode, compression: decorated.stats },
596
+ };
597
+ } catch (err) {
598
+ console.error(`[pi-lean-ctx] ctx_read bridge call failed, falling back to CLI: ${err}`);
599
+ }
600
+ }
601
+
518
602
  const args = ["read", absolutePath, "-m", mode, ...(isExplicitFull ? ["--fresh"] : [])];
519
603
  const output = await execLeanCtx(pi, args);
520
604
  const originalText = await readFile(absolutePath, "utf8");
521
- const decorated = withFooter(output, { originalText, always: true, preferEstimate: true });
605
+ const decorated = withFooter(output, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true });
522
606
 
523
607
  return {
524
608
  content: [{ type: "text", text: decorated.text }],
@@ -528,10 +612,10 @@ export default async function (pi: ExtensionAPI) {
528
612
  });
529
613
 
530
614
  // ── ctx_ls (replaces ls) ──────────────────────────────────────────────
531
- pi.registerTool({
615
+ registerTool({
532
616
  name: "ctx_ls",
533
617
  label: "ctx_ls",
534
- description: "List directory contents. Use limit to reduce output size.",
618
+ description: "List a directory. Prefer over native ls (compact, summarized). Use limit to reduce output size.",
535
619
  promptSnippet: "List directory contents",
536
620
  parameters: lsSchema,
537
621
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -547,10 +631,10 @@ export default async function (pi: ExtensionAPI) {
547
631
  });
548
632
 
549
633
  // ── ctx_find (replaces find) ──────────────────────────────────────────
550
- pi.registerTool({
634
+ registerTool({
551
635
  name: "ctx_find",
552
636
  label: "ctx_find",
553
- description: "Find files by glob pattern (respects .gitignore). Use limit to reduce output size.",
637
+ description: "Find files by glob. Prefer over native find/fd (gitignore-aware). Use limit to reduce output size.",
554
638
  promptSnippet: "Find files by glob pattern",
555
639
  parameters: findSchema,
556
640
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -566,10 +650,10 @@ export default async function (pi: ExtensionAPI) {
566
650
  });
567
651
 
568
652
  // ── ctx_grep (replaces grep) ──────────────────────────────────────────
569
- pi.registerTool({
653
+ registerTool({
570
654
  name: "ctx_grep",
571
655
  label: "ctx_grep",
572
- description: "Search file contents with ripgrep. Use limit to cap matches and context for surrounding lines.",
656
+ description: "Search code. Prefer over native Grep/ripgrep (compact, ranked). Use limit to cap matches, context for surrounding lines.",
573
657
  promptSnippet: "Search file contents for patterns",
574
658
  parameters: grepSchema,
575
659
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -599,7 +683,7 @@ export default async function (pi: ExtensionAPI) {
599
683
  });
600
684
 
601
685
  // ── lean_ctx (CLI passthrough) ────────────────────────────────────────
602
- pi.registerTool({
686
+ registerTool({
603
687
  name: "lean_ctx",
604
688
  label: "lean_ctx",
605
689
  description:
@@ -623,8 +707,11 @@ export default async function (pi: ExtensionAPI) {
623
707
  // is actually serving it — pi has no native MCP support, and `lean-ctx init
624
708
  // --agent pi` writes that entry by default — so it must not silently disable the
625
709
  // bridge a user explicitly requested via LEAN_CTX_PI_ENABLE_MCP=1 / enableMcp.
626
- const mcpBridge = enableMcpBridge
627
- ? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv)
710
+ mcpBridge = enableMcpBridge
711
+ ? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv, {
712
+ disabledTools: PI_CONFIG.disabledTools,
713
+ toolPrefix: PI_CONFIG.toolPrefix,
714
+ })
628
715
  : null;
629
716
 
630
717
  if (mcpBridge) {
@@ -676,6 +763,22 @@ export default async function (pi: ExtensionAPI) {
676
763
  }
677
764
  }
678
765
 
766
+ // Coexistence diagnostics (#359): the active prefix plus which tools we
767
+ // handed off or skipped, so a user stacking AFT / magic-context can see
768
+ // the exact split at a glance.
769
+ const skipped = [...(status?.skippedTools ?? []), ...skippedExtensionTools];
770
+ const disabled = [...(status?.disabledTools ?? []), ...disabledExtensionTools];
771
+ const prefix = status?.toolPrefix ?? PI_CONFIG.toolPrefix;
772
+ if (prefix) {
773
+ lines.push(`Tool prefix: "${prefix}" (bridge tools exposed as ${prefix}<name>)`);
774
+ }
775
+ if (disabled.length > 0) {
776
+ lines.push(`Disabled (handed to other extensions): ${disabled.join(", ")}`);
777
+ }
778
+ if (skipped.length > 0) {
779
+ lines.push(`Skipped (name already taken): ${skipped.join(", ")}`);
780
+ }
781
+
679
782
  // Show active ctx_ tools
680
783
  const ctxTools = pi.getActiveTools().filter((n) => n.startsWith("ctx_") || n === "lean_ctx");
681
784
  if (ctxTools.length > 0) {
@@ -27,6 +27,20 @@ type McpTool = {
27
27
  inputSchema?: Record<string, unknown>;
28
28
  };
29
29
 
30
+ /**
31
+ * How the bridge should expose discovered MCP tools, so lean-ctx can coexist
32
+ * with other Pi extensions (AFT, magic-context) instead of crashing on a name
33
+ * collision (issue #359).
34
+ */
35
+ export type BridgeToolPolicy = {
36
+ /** Lower-cased tool names the bridge must not register at all. */
37
+ disabledTools: Set<string>;
38
+ /** Optional prefix applied to the Pi-facing tool name (not the MCP call). */
39
+ toolPrefix?: string;
40
+ };
41
+
42
+ const DEFAULT_TOOL_POLICY: BridgeToolPolicy = { disabledTools: new Set() };
43
+
30
44
  function isAbortLikeError(error: unknown): boolean {
31
45
  if (!(error instanceof Error)) return false;
32
46
  const msg = error.message.toLowerCase();
@@ -57,17 +71,25 @@ export class McpBridge {
57
71
  private client: Client | null = null;
58
72
  private transport: StdioClientTransport | null = null;
59
73
  private registeredTools: string[] = [];
74
+ private skippedTools: string[] = [];
75
+ private disabledToolNames: string[] = [];
60
76
  private connected = false;
61
77
  private binary: string;
62
78
  private extraEnv: Record<string, string>;
79
+ private policy: BridgeToolPolicy;
63
80
  private reconnectAttempts = 0;
64
81
  private lastError: string | undefined;
65
82
  private lastHungTool: string | undefined;
66
83
  private lastRetry: McpBridgeRetryState | undefined;
67
84
 
68
- constructor(binary: string, extraEnv: Record<string, string> = {}) {
85
+ constructor(
86
+ binary: string,
87
+ extraEnv: Record<string, string> = {},
88
+ policy: BridgeToolPolicy = DEFAULT_TOOL_POLICY,
89
+ ) {
69
90
  this.binary = binary;
70
91
  this.extraEnv = extraEnv;
92
+ this.policy = policy;
71
93
  }
72
94
 
73
95
  async start(pi: ExtensionAPI): Promise<void> {
@@ -155,6 +177,10 @@ export class McpBridge {
155
177
 
156
178
  for (const tool of tools) {
157
179
  if (CLI_OVERRIDE_TOOLS.has(tool.name)) continue;
180
+ if (this.policy.disabledTools.has(tool.name.toLowerCase())) {
181
+ this.disabledToolNames.push(tool.name);
182
+ continue;
183
+ }
158
184
  this.registerMcpTool(pi, tool);
159
185
  }
160
186
  }
@@ -162,25 +188,41 @@ export class McpBridge {
162
188
  private registerMcpTool(pi: ExtensionAPI, tool: McpTool): void {
163
189
  const bridge = this;
164
190
  const schema = this.jsonSchemaToTypebox(tool.inputSchema);
191
+ // The prefix renames only the Pi-facing tool; the MCP call still targets
192
+ // the real `tool.name` captured in the closure below.
193
+ const exposedName = this.policy.toolPrefix
194
+ ? `${this.policy.toolPrefix}${tool.name}`
195
+ : tool.name;
165
196
 
166
- pi.registerTool({
167
- name: tool.name,
168
- label: tool.name,
169
- description: tool.description ?? `lean-ctx MCP tool: ${tool.name}`,
170
- promptSnippet: tool.description ?? tool.name,
171
- parameters: schema,
172
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
173
- const result = await bridge.callTool(
174
- tool.name,
175
- params as Record<string, unknown>,
176
- signal,
177
- );
178
- // Pi's AgentToolResult requires a `details` field; MCP tool output has none.
179
- return { ...result, details: undefined };
180
- },
181
- });
182
-
183
- this.registeredTools.push(tool.name);
197
+ try {
198
+ pi.registerTool({
199
+ name: exposedName,
200
+ label: exposedName,
201
+ description: tool.description ?? `lean-ctx MCP tool: ${tool.name}`,
202
+ promptSnippet: tool.description ?? tool.name,
203
+ parameters: schema,
204
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
205
+ const result = await bridge.callTool(
206
+ tool.name,
207
+ params as Record<string, unknown>,
208
+ signal,
209
+ );
210
+ // Pi's AgentToolResult requires a `details` field; MCP tool output has none.
211
+ return { ...result, details: undefined };
212
+ },
213
+ });
214
+ this.registeredTools.push(exposedName);
215
+ } catch (err) {
216
+ // Another extension (e.g. magic-context) already owns this name. Skip it
217
+ // and keep going so the whole agent doesn't crash on load (#359). Set a
218
+ // prefix (LEAN_CTX_PI_TOOL_PREFIX) or disable the tool to resolve cleanly.
219
+ const msg = err instanceof Error ? err.message : String(err);
220
+ this.skippedTools.push(exposedName);
221
+ console.error(
222
+ `[lean-ctx MCP bridge] Skipped tool "${exposedName}" — already registered by another extension? (${msg}). `
223
+ + "Set LEAN_CTX_PI_TOOL_PREFIX or add it to LEAN_CTX_PI_DISABLE_TOOLS to silence this.",
224
+ );
225
+ }
184
226
  }
185
227
 
186
228
  async callTool(
@@ -341,12 +383,20 @@ export class McpBridge {
341
383
  return Type.Object(fields);
342
384
  }
343
385
 
386
+ /** True when the MCP client is connected and able to serve tool calls. */
387
+ isConnected(): boolean {
388
+ return this.connected && this.client !== null;
389
+ }
390
+
344
391
  getStatus(): McpBridgeStatus {
345
392
  return {
346
393
  mode: "embedded",
347
394
  connected: this.connected,
348
395
  toolCount: this.registeredTools.length,
349
396
  toolNames: [...this.registeredTools],
397
+ skippedTools: [...this.skippedTools],
398
+ disabledTools: [...this.disabledToolNames],
399
+ toolPrefix: this.policy.toolPrefix,
350
400
  reconnectAttempts: this.reconnectAttempts,
351
401
  lastError: this.lastError,
352
402
  lastHungTool: this.lastHungTool,
@@ -16,6 +16,12 @@ export type McpBridgeStatus = {
16
16
  connected: boolean;
17
17
  toolCount: number;
18
18
  toolNames: string[];
19
+ /** Tools skipped because another extension already claimed the name (#359). */
20
+ skippedTools: string[];
21
+ /** Tools not registered because the user disabled them via config (#359). */
22
+ disabledTools: string[];
23
+ /** Active prefix applied to bridge tool names, if any (#359). */
24
+ toolPrefix?: string;
19
25
  reconnectAttempts: number;
20
26
  lastError?: string;
21
27
  lastHungTool?: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-lean-ctx",
3
- "version": "3.7.4",
4
- "description": "Pi Coding Agent extension (CLI-first) \u2014 routes bash/read/grep/find/ls through lean-ctx CLI for strong token savings. Optional MCP bridge can register advanced tools.",
3
+ "version": "3.8.0",
4
+ "description": "Pi Coding Agent extension \u2014 routes bash/read/grep/find/ls through lean-ctx for strong token savings. The embedded MCP bridge (on by default) adds a persistent session cache so unchanged re-reads cost ~13 tokens.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "lean-ctx",