pi-lean-ctx 3.7.5 → 3.8.1
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 +119 -3
- package/extensions/config.ts +58 -0
- package/extensions/index.ts +64 -11
- package/extensions/mcp-bridge.ts +64 -19
- package/extensions/types.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
|
@@ -140,6 +144,47 @@ pi
|
|
|
140
144
|
|
|
141
145
|
…or set `"enableMcp": false` in `~/.pi/agent/extensions/pi-lean-ctx/config.json`.
|
|
142
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:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
lean-ctx verify-cache
|
|
155
|
+
```
|
|
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
|
+
|
|
143
188
|
## pi-mcp-adapter compatibility
|
|
144
189
|
|
|
145
190
|
If you prefer using [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) to manage your MCP servers, lean-ctx integrates automatically:
|
|
@@ -195,6 +240,8 @@ Use `/lean-ctx` in Pi to check:
|
|
|
195
240
|
- Which binary is being used
|
|
196
241
|
- MCP bridge status (disabled / embedded / adapter)
|
|
197
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`)
|
|
198
245
|
|
|
199
246
|
## Disabling specific tools
|
|
200
247
|
|
|
@@ -210,6 +257,75 @@ Or via environment variable:
|
|
|
210
257
|
LEAN_CTX_DISABLED_TOOLS=ctx_graph,ctx_benchmark pi
|
|
211
258
|
```
|
|
212
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
|
+
|
|
213
329
|
## Links
|
|
214
330
|
|
|
215
331
|
- [lean-ctx](https://leanctx.com) — the Cognitive Context Layer for AI coding agents
|
package/extensions/config.ts
CHANGED
|
@@ -30,6 +30,22 @@ export interface PiLeanCtxFileConfig {
|
|
|
30
30
|
* (e.g. `{ "LEAN_CTX_COMPRESSION": "aggressive" }`).
|
|
31
31
|
*/
|
|
32
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;
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
export type PiMode = "additive" | "replace";
|
|
@@ -42,6 +58,10 @@ export interface ResolvedPiConfig {
|
|
|
42
58
|
binaryOverride?: string;
|
|
43
59
|
/** Engine env overrides forwarded to lean-ctx subprocesses. */
|
|
44
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;
|
|
45
65
|
/** Absolute path the loader looked at (whether or not it existed). */
|
|
46
66
|
configPath: string;
|
|
47
67
|
/** True when the file existed and parsed into a JSON object. */
|
|
@@ -87,6 +107,42 @@ function resolveMode(fileMode: string | undefined): PiMode {
|
|
|
87
107
|
return raw === "replace" ? "replace" : "additive";
|
|
88
108
|
}
|
|
89
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
|
+
|
|
90
146
|
/**
|
|
91
147
|
* Loads and resolves the Pi override config. Precedence per setting is
|
|
92
148
|
* "most explicit wins": an explicit `LEAN_CTX_PI_*` / `LEAN_CTX_BIN` env var
|
|
@@ -123,6 +179,8 @@ export function loadPiConfig(): ResolvedPiConfig {
|
|
|
123
179
|
enableMcp,
|
|
124
180
|
binaryOverride,
|
|
125
181
|
forwardedEnv,
|
|
182
|
+
disabledTools: resolveDisabledTools(cfg.disableTools),
|
|
183
|
+
toolPrefix: resolveToolPrefix(cfg.toolPrefix),
|
|
126
184
|
configPath,
|
|
127
185
|
loaded,
|
|
128
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 {
|
|
@@ -332,6 +341,31 @@ export default async function (pi: ExtensionAPI) {
|
|
|
332
341
|
// registered (the bridge is started at the end of this function).
|
|
333
342
|
let mcpBridge: McpBridge | null = null;
|
|
334
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
|
+
|
|
335
369
|
const baseBashTool = createBashToolDefinition(process.cwd(), {
|
|
336
370
|
spawnHook: ({ command, cwd, env }) => {
|
|
337
371
|
const bin = resolveBinary();
|
|
@@ -352,7 +386,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
352
386
|
});
|
|
353
387
|
|
|
354
388
|
// ── ctx_shell (replaces bash) ─────────────────────────────────────────
|
|
355
|
-
|
|
389
|
+
registerTool({
|
|
356
390
|
name: "ctx_shell",
|
|
357
391
|
label: "ctx_shell",
|
|
358
392
|
description:
|
|
@@ -413,7 +447,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
413
447
|
// ── ctx_read (replaces read) ──────────────────────────────────────────
|
|
414
448
|
const nativeReadTool = createReadToolDefinition(process.cwd());
|
|
415
449
|
|
|
416
|
-
|
|
450
|
+
registerTool({
|
|
417
451
|
name: "ctx_read",
|
|
418
452
|
label: "ctx_read",
|
|
419
453
|
description:
|
|
@@ -505,7 +539,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
505
539
|
const bridged = await mcpBridge.callTool("ctx_read", { path: absolutePath, mode }, signal);
|
|
506
540
|
const bridgedText = bridged.content.map((block) => block.text).join("");
|
|
507
541
|
const originalSlice = await readSlice(absolutePath, params.offset, params.limit);
|
|
508
|
-
const decorated = withFooter(bridgedText, { originalText: originalSlice.text, always: true, preferEstimate: true });
|
|
542
|
+
const decorated = withFooter(bridgedText, { originalText: originalSlice.text, always: true, preferEstimate: true, suppressIfNoSaving: true });
|
|
509
543
|
return {
|
|
510
544
|
content: [{ type: "text", text: decorated.text }],
|
|
511
545
|
details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx-bridge", mode, compression: decorated.stats },
|
|
@@ -518,7 +552,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
518
552
|
try {
|
|
519
553
|
const output = await execLeanCtx(pi, args);
|
|
520
554
|
const originalSlice = await readSlice(absolutePath, params.offset, params.limit);
|
|
521
|
-
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 });
|
|
522
556
|
return {
|
|
523
557
|
content: [{ type: "text", text: decorated.text }],
|
|
524
558
|
details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx", mode, compression: decorated.stats },
|
|
@@ -555,7 +589,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
555
589
|
);
|
|
556
590
|
const bridgedText = bridged.content.map((block) => block.text).join("");
|
|
557
591
|
const originalText = await readFile(absolutePath, "utf8");
|
|
558
|
-
const decorated = withFooter(bridgedText, { originalText, always: true, preferEstimate: true });
|
|
592
|
+
const decorated = withFooter(bridgedText, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true });
|
|
559
593
|
return {
|
|
560
594
|
content: [{ type: "text", text: decorated.text }],
|
|
561
595
|
details: { path: absolutePath, source: "lean-ctx-bridge", mode, compression: decorated.stats },
|
|
@@ -568,7 +602,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
568
602
|
const args = ["read", absolutePath, "-m", mode, ...(isExplicitFull ? ["--fresh"] : [])];
|
|
569
603
|
const output = await execLeanCtx(pi, args);
|
|
570
604
|
const originalText = await readFile(absolutePath, "utf8");
|
|
571
|
-
const decorated = withFooter(output, { originalText, always: true, preferEstimate: true });
|
|
605
|
+
const decorated = withFooter(output, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true });
|
|
572
606
|
|
|
573
607
|
return {
|
|
574
608
|
content: [{ type: "text", text: decorated.text }],
|
|
@@ -578,7 +612,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
578
612
|
});
|
|
579
613
|
|
|
580
614
|
// ── ctx_ls (replaces ls) ──────────────────────────────────────────────
|
|
581
|
-
|
|
615
|
+
registerTool({
|
|
582
616
|
name: "ctx_ls",
|
|
583
617
|
label: "ctx_ls",
|
|
584
618
|
description: "List a directory. Prefer over native ls (compact, summarized). Use limit to reduce output size.",
|
|
@@ -597,7 +631,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
597
631
|
});
|
|
598
632
|
|
|
599
633
|
// ── ctx_find (replaces find) ──────────────────────────────────────────
|
|
600
|
-
|
|
634
|
+
registerTool({
|
|
601
635
|
name: "ctx_find",
|
|
602
636
|
label: "ctx_find",
|
|
603
637
|
description: "Find files by glob. Prefer over native find/fd (gitignore-aware). Use limit to reduce output size.",
|
|
@@ -616,7 +650,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
616
650
|
});
|
|
617
651
|
|
|
618
652
|
// ── ctx_grep (replaces grep) ──────────────────────────────────────────
|
|
619
|
-
|
|
653
|
+
registerTool({
|
|
620
654
|
name: "ctx_grep",
|
|
621
655
|
label: "ctx_grep",
|
|
622
656
|
description: "Search code. Prefer over native Grep/ripgrep (compact, ranked). Use limit to cap matches, context for surrounding lines.",
|
|
@@ -649,7 +683,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
649
683
|
});
|
|
650
684
|
|
|
651
685
|
// ── lean_ctx (CLI passthrough) ────────────────────────────────────────
|
|
652
|
-
|
|
686
|
+
registerTool({
|
|
653
687
|
name: "lean_ctx",
|
|
654
688
|
label: "lean_ctx",
|
|
655
689
|
description:
|
|
@@ -674,7 +708,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
674
708
|
// --agent pi` writes that entry by default — so it must not silently disable the
|
|
675
709
|
// bridge a user explicitly requested via LEAN_CTX_PI_ENABLE_MCP=1 / enableMcp.
|
|
676
710
|
mcpBridge = enableMcpBridge
|
|
677
|
-
? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv
|
|
711
|
+
? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv, {
|
|
712
|
+
disabledTools: PI_CONFIG.disabledTools,
|
|
713
|
+
toolPrefix: PI_CONFIG.toolPrefix,
|
|
714
|
+
})
|
|
678
715
|
: null;
|
|
679
716
|
|
|
680
717
|
if (mcpBridge) {
|
|
@@ -726,6 +763,22 @@ export default async function (pi: ExtensionAPI) {
|
|
|
726
763
|
}
|
|
727
764
|
}
|
|
728
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
|
+
|
|
729
782
|
// Show active ctx_ tools
|
|
730
783
|
const ctxTools = pi.getActiveTools().filter((n) => n.startsWith("ctx_") || n === "lean_ctx");
|
|
731
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(
|
|
@@ -352,6 +394,9 @@ export class McpBridge {
|
|
|
352
394
|
connected: this.connected,
|
|
353
395
|
toolCount: this.registeredTools.length,
|
|
354
396
|
toolNames: [...this.registeredTools],
|
|
397
|
+
skippedTools: [...this.skippedTools],
|
|
398
|
+
disabledTools: [...this.disabledToolNames],
|
|
399
|
+
toolPrefix: this.policy.toolPrefix,
|
|
355
400
|
reconnectAttempts: this.reconnectAttempts,
|
|
356
401
|
lastError: this.lastError,
|
|
357
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-ctx",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.1",
|
|
4
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",
|