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 +136 -15
- package/extensions/config.ts +69 -2
- package/extensions/index.ts +121 -18
- package/extensions/mcp-bridge.ts +69 -19
- package/extensions/types.ts +6 -0
- package/package.json +2 -2
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**:
|
|
6
|
-
- **
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
###
|
|
115
|
+
### Embedded MCP bridge (session cache + advanced tools)
|
|
112
116
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
135
|
+
## Disabling the bridge (optional)
|
|
124
136
|
|
|
125
|
-
|
|
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=
|
|
141
|
+
export LEAN_CTX_PI_ENABLE_MCP=0
|
|
129
142
|
pi
|
|
130
143
|
```
|
|
131
144
|
|
|
132
|
-
|
|
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
|
|
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
|
package/extensions/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
};
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
389
|
+
registerTool({
|
|
351
390
|
name: "ctx_shell",
|
|
352
391
|
label: "ctx_shell",
|
|
353
392
|
description:
|
|
354
|
-
"
|
|
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
|
-
|
|
450
|
+
registerTool({
|
|
412
451
|
name: "ctx_read",
|
|
413
452
|
label: "ctx_read",
|
|
414
453
|
description:
|
|
415
|
-
"Read file
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
615
|
+
registerTool({
|
|
532
616
|
name: "ctx_ls",
|
|
533
617
|
label: "ctx_ls",
|
|
534
|
-
description: "List directory
|
|
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
|
-
|
|
634
|
+
registerTool({
|
|
551
635
|
name: "ctx_find",
|
|
552
636
|
label: "ctx_find",
|
|
553
|
-
description: "Find files by glob
|
|
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
|
-
|
|
653
|
+
registerTool({
|
|
570
654
|
name: "ctx_grep",
|
|
571
655
|
label: "ctx_grep",
|
|
572
|
-
description: "Search
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/extensions/mcp-bridge.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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,
|
package/extensions/types.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Pi Coding Agent extension
|
|
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",
|