agent-sh 0.12.20 → 0.12.22
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 +11 -3
- package/dist/agent/agent-loop.d.ts +1 -0
- package/dist/agent/agent-loop.js +30 -5
- package/dist/agent/conversation-state.d.ts +3 -2
- package/dist/agent/conversation-state.js +27 -14
- package/dist/agent/normalize-args.d.ts +29 -0
- package/dist/agent/normalize-args.js +56 -0
- package/dist/agent/subagent.js +2 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +16 -22
- package/dist/event-bus.d.ts +9 -2
- package/dist/event-bus.js +9 -0
- package/dist/extensions/agent-backend.js +104 -24
- package/dist/extensions/index.js +8 -3
- package/dist/extensions/providers/deepseek.d.ts +8 -0
- package/dist/extensions/providers/deepseek.js +23 -0
- package/dist/extensions/providers/openai-compatible.d.ts +7 -0
- package/dist/extensions/providers/openai-compatible.js +30 -0
- package/dist/extensions/providers/openai.d.ts +7 -0
- package/dist/extensions/providers/openai.js +39 -0
- package/dist/extensions/{openrouter.d.ts → providers/openrouter.d.ts} +1 -1
- package/dist/extensions/{openrouter.js → providers/openrouter.js} +5 -3
- package/dist/extensions/slash-commands.js +0 -24
- package/dist/extensions/tui-renderer.js +28 -15
- package/dist/index.js +8 -33
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +1 -0
- package/dist/types.d.ts +14 -1
- package/dist/utils/box-frame.js +14 -8
- package/dist/utils/llm-client.d.ts +5 -1
- package/dist/utils/llm-client.js +6 -1
- package/dist/utils/llm-facade.js +5 -5
- package/examples/extensions/pi-bridge/README.md +12 -19
- package/examples/extensions/pi-bridge/index.ts +307 -35
- package/package.json +1 -1
- package/dist/extensions/openai.d.ts +0 -9
- package/dist/extensions/openai.js +0 -49
package/dist/utils/box-frame.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* never writes to stdout. Supports multiple border styles and
|
|
6
6
|
* optional title/footer sections with dividers.
|
|
7
7
|
*/
|
|
8
|
-
import { visibleLen, truncateToWidth } from "./ansi.js";
|
|
8
|
+
import { visibleLen, truncateToWidth, truncateAnsiToWidth } from "./ansi.js";
|
|
9
9
|
import { palette as p } from "./palette.js";
|
|
10
10
|
const BORDERS = {
|
|
11
11
|
rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", ml: "├", mr: "┤" },
|
|
@@ -32,14 +32,20 @@ export function renderBoxFrame(content, opts) {
|
|
|
32
32
|
const output = [];
|
|
33
33
|
// Top border (with optional left/right titles)
|
|
34
34
|
if (opts.title || opts.titleRight) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const rightPart = opts.titleRight
|
|
40
|
-
? `${p.reset} ${opts.titleRight} ${bc}`
|
|
41
|
-
: "";
|
|
35
|
+
// Budget: 2 corners + 1 minimum dash + space-padding around each title.
|
|
36
|
+
// Truncate the left title first if combined widths overflow — titleRight
|
|
37
|
+
// is typically short metadata (model name, stats) worth preserving.
|
|
38
|
+
let title = opts.title;
|
|
42
39
|
const rightVis = opts.titleRight ? visibleLen(opts.titleRight) + 2 : 0;
|
|
40
|
+
const leftBudget = width - 2 - 1 - rightVis; // total - corners - min dash - right
|
|
41
|
+
let leftVis = title ? visibleLen(title) + 2 : 0;
|
|
42
|
+
if (title && leftVis > leftBudget) {
|
|
43
|
+
const maxTitleVis = Math.max(1, leftBudget - 2);
|
|
44
|
+
title = truncateAnsiToWidth(title, maxTitleVis);
|
|
45
|
+
leftVis = visibleLen(title) + 2;
|
|
46
|
+
}
|
|
47
|
+
const leftPart = title ? `${p.reset} ${title} ${bc}` : "";
|
|
48
|
+
const rightPart = opts.titleRight ? `${p.reset} ${opts.titleRight} ${bc}` : "";
|
|
43
49
|
const dashCount = Math.max(1, width - 2 - leftVis - rightVis);
|
|
44
50
|
output.push(`${bc}${b.tl}${leftPart}${b.h.repeat(dashCount)}${rightPart}${b.tr}${p.reset}`);
|
|
45
51
|
}
|
|
@@ -33,7 +33,8 @@ export declare class LlmClient {
|
|
|
33
33
|
tools?: ChatCompletionTool[];
|
|
34
34
|
model?: string;
|
|
35
35
|
max_tokens?: number;
|
|
36
|
-
/** Reasoning effort
|
|
36
|
+
/** Reasoning effort: "off" | "low" | "medium" | "high". Provider-dependent;
|
|
37
|
+
* "off" matches agent-loop's thinkingLevel and omits the field. */
|
|
37
38
|
reasoning_effort?: string;
|
|
38
39
|
signal?: AbortSignal;
|
|
39
40
|
}): import("openai").APIPromise<import("openai/core/streaming.mjs").Stream<OpenAI.Chat.Completions.ChatCompletionChunk>>;
|
|
@@ -45,5 +46,8 @@ export declare class LlmClient {
|
|
|
45
46
|
messages: ChatCompletionMessageParam[];
|
|
46
47
|
model?: string;
|
|
47
48
|
max_tokens?: number;
|
|
49
|
+
/** Reasoning effort: "off" | "low" | "medium" | "high". Provider-dependent;
|
|
50
|
+
* "off" matches agent-loop's thinkingLevel and omits the field. */
|
|
51
|
+
reasoning_effort?: string;
|
|
48
52
|
}): Promise<string>;
|
|
49
53
|
}
|
package/dist/utils/llm-client.js
CHANGED
|
@@ -40,6 +40,7 @@ export class LlmClient {
|
|
|
40
40
|
* Returns an async iterable of chunks.
|
|
41
41
|
*/
|
|
42
42
|
stream(opts) {
|
|
43
|
+
const sendEffort = opts.reasoning_effort && opts.reasoning_effort !== "off";
|
|
43
44
|
const body = {
|
|
44
45
|
model: opts.model ?? this.model,
|
|
45
46
|
messages: opts.messages,
|
|
@@ -47,7 +48,7 @@ export class LlmClient {
|
|
|
47
48
|
max_tokens: opts.max_tokens ?? 65536,
|
|
48
49
|
stream: true,
|
|
49
50
|
stream_options: { include_usage: true },
|
|
50
|
-
...(
|
|
51
|
+
...(sendEffort
|
|
51
52
|
? { reasoning_effort: opts.reasoning_effort }
|
|
52
53
|
: {}),
|
|
53
54
|
};
|
|
@@ -58,10 +59,14 @@ export class LlmClient {
|
|
|
58
59
|
* Returns the text content of the first choice.
|
|
59
60
|
*/
|
|
60
61
|
async complete(opts) {
|
|
62
|
+
const sendEffort = opts.reasoning_effort && opts.reasoning_effort !== "off";
|
|
61
63
|
const response = await this.client.chat.completions.create({
|
|
62
64
|
model: opts.model ?? this.model,
|
|
63
65
|
messages: opts.messages,
|
|
64
66
|
max_tokens: opts.max_tokens ?? 1024,
|
|
67
|
+
...(sendEffort
|
|
68
|
+
? { reasoning_effort: opts.reasoning_effort }
|
|
69
|
+
: {}),
|
|
65
70
|
});
|
|
66
71
|
return response.choices[0]?.message?.content ?? "";
|
|
67
72
|
}
|
package/dist/utils/llm-facade.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
export function createLlmFacade(handlers) {
|
|
2
|
-
const invoke = (messages, maxTokens) => {
|
|
3
|
-
const result = handlers.call("llm:invoke", messages, { maxTokens });
|
|
2
|
+
const invoke = (messages, maxTokens, model, reasoningEffort) => {
|
|
3
|
+
const result = handlers.call("llm:invoke", messages, { maxTokens, model, reasoningEffort });
|
|
4
4
|
if (result === undefined)
|
|
5
5
|
return Promise.reject(new Error("ctx.llm: no LLM backend available"));
|
|
6
6
|
return result;
|
|
7
7
|
};
|
|
8
8
|
return {
|
|
9
9
|
get available() { return handlers.list().includes("llm:invoke"); },
|
|
10
|
-
ask: ({ query, system, maxTokens }) => {
|
|
10
|
+
ask: ({ query, system, maxTokens, model, reasoningEffort }) => {
|
|
11
11
|
const messages = [];
|
|
12
12
|
if (system)
|
|
13
13
|
messages.push({ role: "system", content: system });
|
|
14
14
|
messages.push({ role: "user", content: query });
|
|
15
|
-
return invoke(messages, maxTokens);
|
|
15
|
+
return invoke(messages, maxTokens, model, reasoningEffort);
|
|
16
16
|
},
|
|
17
17
|
session: (opts = {}) => {
|
|
18
18
|
const messages = [];
|
|
@@ -21,7 +21,7 @@ export function createLlmFacade(handlers) {
|
|
|
21
21
|
const session = {
|
|
22
22
|
async send(message) {
|
|
23
23
|
messages.push({ role: "user", content: message });
|
|
24
|
-
const reply = await invoke(messages, opts.maxTokens);
|
|
24
|
+
const reply = await invoke(messages, opts.maxTokens, opts.model, opts.reasoningEffort);
|
|
25
25
|
messages.push({ role: "assistant", content: reply });
|
|
26
26
|
return reply;
|
|
27
27
|
},
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
# pi-bridge
|
|
2
2
|
|
|
3
|
-
Runs [pi](https://github.com/
|
|
3
|
+
Runs [pi](https://github.com/badlogic/pi-mono) (`@mariozechner/pi-coding-agent`) as an agent-sh backend. Pi brings its own configuration, models, tools, and extensions — agent-sh just provides the terminal.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
# Copy or symlink into your extensions directory
|
|
9
8
|
cp -r examples/extensions/pi-bridge ~/.agent-sh/extensions/pi-bridge
|
|
10
|
-
|
|
11
|
-
# Install dependencies
|
|
12
9
|
cd ~/.agent-sh/extensions/pi-bridge
|
|
13
10
|
npm install
|
|
14
11
|
```
|
|
@@ -26,26 +23,22 @@ Set as default backend in `~/.agent-sh/settings.json`:
|
|
|
26
23
|
Or switch at runtime:
|
|
27
24
|
|
|
28
25
|
```
|
|
29
|
-
|
|
26
|
+
> /backend pi
|
|
30
27
|
```
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- pi must be configured separately (`~/.pi/settings.json`) with API keys and model preferences
|
|
35
|
-
- agent-sh does not override pi's configuration — it uses whatever pi is set up with
|
|
29
|
+
Pi reads its own settings from `~/.pi/agent/settings.json`. Configure API keys and model preferences there (or run `pi` directly to set up auth) — agent-sh does not override pi's configuration.
|
|
36
30
|
|
|
37
|
-
## What
|
|
31
|
+
## What works under pi
|
|
38
32
|
|
|
39
|
-
|
|
33
|
+
These slash commands are routed to pi's SDK when pi is the active backend:
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
- `/model` — lists/switches pi's available models (`session.setModel`)
|
|
36
|
+
- `/thinking` — sets pi's thinking level (`off/minimal/low/medium/high/xhigh`)
|
|
37
|
+
- `/compact` — runs `session.compact()` on pi's session
|
|
38
|
+
- `/context` — reports pi's token usage (`session.getContextUsage()`)
|
|
42
39
|
|
|
43
|
-
|
|
40
|
+
agent-sh's per-query context producers (e.g. `<shell_events>` from `shell-context`) are inlined into pi's prompt before each query, so pi sees the user's recent shell activity even though it doesn't subscribe to agent-sh's shell bus directly.
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
- `terminal_keys` — send keystrokes to the user's PTY
|
|
47
|
-
- `user_shell` — run commands in the user's live shell with lasting `cd`/`export`/`source` effects
|
|
48
|
-
|
|
49
|
-
These are opt-in capabilities that belong in their own extensions. If you want any of them with pi, write a small companion extension that registers the tool as a pi `ToolDefinition` (TypeBox schema, wired to the relevant bus event: `shell:pty-write`, `shell:exec-request`, or `ctx.terminalBuffer.readScreen()`) and load it alongside pi-bridge.
|
|
42
|
+
## What this bridge is
|
|
50
43
|
|
|
51
|
-
|
|
44
|
+
A pure protocol translator between pi's event stream and agent-sh's bus events. Pi's built-in tools (command execution, file ops, etc.) are used exactly as pi ships them. The bridge adds no tools of its own.
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pi bridge — runs pi's full coding agent in-process as agent-sh's backend.
|
|
3
|
-
*
|
|
4
|
-
* Uses pi's own AgentSession with its full configuration: model registry,
|
|
5
|
-
* provider settings, extensions, session management, and tool system.
|
|
6
|
-
* Agent-sh provides the shell frontend and TUI rendering.
|
|
7
|
-
*
|
|
8
|
-
* The bridge is a pure protocol translator between pi's event stream and
|
|
9
|
-
* agent-sh's bus events. Pi brings its own tools for command execution,
|
|
10
|
-
* file ops, etc. PTY-access tools (`terminal_read`, `terminal_keys`,
|
|
11
|
-
* `user_shell`) are intentionally NOT bundled here — if you want pi to
|
|
12
|
-
* observe or mutate the user's live terminal, load a companion extension
|
|
13
|
-
* that registers those tools in pi's ToolDefinition format.
|
|
3
|
+
* Pure protocol translator between pi's event stream and agent-sh's bus.
|
|
14
4
|
*
|
|
15
5
|
* Setup:
|
|
16
6
|
* npm install @mariozechner/pi-agent-core @mariozechner/pi-ai @mariozechner/pi-coding-agent
|
|
@@ -26,25 +16,127 @@ import {
|
|
|
26
16
|
SessionManager,
|
|
27
17
|
} from "@mariozechner/pi-coding-agent";
|
|
28
18
|
import type { ExtensionContext } from "agent-sh/types";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { resolve as resolvePath } from "node:path";
|
|
21
|
+
import { diffLines } from "diff";
|
|
22
|
+
|
|
23
|
+
const TOOL_KINDS: Record<string, string> = {
|
|
24
|
+
bash: "execute",
|
|
25
|
+
read: "read",
|
|
26
|
+
ls: "read",
|
|
27
|
+
find: "read",
|
|
28
|
+
grep: "search",
|
|
29
|
+
edit: "execute",
|
|
30
|
+
write: "execute",
|
|
31
|
+
};
|
|
32
|
+
const kindForTool = (name: string): string => TOOL_KINDS[name] ?? "execute";
|
|
33
|
+
|
|
34
|
+
type DiffLineRecord = { type: "context" | "added" | "removed"; oldNo: number | null; newNo: number | null; text: string };
|
|
35
|
+
type DiffHunkRecord = { lines: DiffLineRecord[] };
|
|
36
|
+
type DiffResultRecord = { hunks: DiffHunkRecord[]; added: number; removed: number; isIdentical: boolean; isNewFile: boolean };
|
|
37
|
+
|
|
38
|
+
function buildDiffFromTexts(oldText: string, newText: string, isNewFile: boolean): DiffResultRecord | null {
|
|
39
|
+
if (oldText === newText) return null;
|
|
40
|
+
const changes = diffLines(oldText, newText);
|
|
41
|
+
const allLines: DiffLineRecord[] = [];
|
|
42
|
+
let oldNo = 0;
|
|
43
|
+
let newNo = 0;
|
|
44
|
+
let added = 0;
|
|
45
|
+
let removed = 0;
|
|
46
|
+
for (const change of changes) {
|
|
47
|
+
const lines = change.value.replace(/\n$/, "").split("\n");
|
|
48
|
+
for (const text of lines) {
|
|
49
|
+
if (change.added) {
|
|
50
|
+
newNo++;
|
|
51
|
+
allLines.push({ type: "added", oldNo: null, newNo, text });
|
|
52
|
+
added++;
|
|
53
|
+
} else if (change.removed) {
|
|
54
|
+
oldNo++;
|
|
55
|
+
allLines.push({ type: "removed", oldNo, newNo: null, text });
|
|
56
|
+
removed++;
|
|
57
|
+
} else {
|
|
58
|
+
oldNo++;
|
|
59
|
+
newNo++;
|
|
60
|
+
allLines.push({ type: "context", oldNo, newNo, text });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (allLines.length === 0) return null;
|
|
65
|
+
return {
|
|
66
|
+
hunks: [{ lines: allLines }],
|
|
67
|
+
added,
|
|
68
|
+
removed,
|
|
69
|
+
isIdentical: false,
|
|
70
|
+
isNewFile,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pi's edit returns a custom diff string: prefix(+/-/space) + lineNum + " " + text, "..." between hunks.
|
|
75
|
+
function parsePiDiff(raw: unknown): DiffResultRecord | null {
|
|
76
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
77
|
+
const hunks: DiffHunkRecord[] = [];
|
|
78
|
+
let current: DiffLineRecord[] = [];
|
|
79
|
+
let added = 0;
|
|
80
|
+
let removed = 0;
|
|
81
|
+
let hasOriginal = false;
|
|
82
|
+
let delta = 0;
|
|
83
|
+
|
|
84
|
+
const flush = () => {
|
|
85
|
+
if (current.length > 0) hunks.push({ lines: current });
|
|
86
|
+
current = [];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (const line of raw.split("\n")) {
|
|
90
|
+
if (line.length === 0) continue;
|
|
91
|
+
const prefix = line[0];
|
|
92
|
+
const rest = line.slice(1);
|
|
93
|
+
if (prefix === " " && rest.trim() === "...") { flush(); continue; }
|
|
94
|
+
const m = rest.match(/^\s*(\d+)\s(.*)$/);
|
|
95
|
+
if (!m) continue;
|
|
96
|
+
const num = parseInt(m[1]!, 10);
|
|
97
|
+
const text = m[2]!;
|
|
98
|
+
if (prefix === "+") {
|
|
99
|
+
current.push({ type: "added", oldNo: null, newNo: num, text });
|
|
100
|
+
added++;
|
|
101
|
+
delta++;
|
|
102
|
+
} else if (prefix === "-") {
|
|
103
|
+
current.push({ type: "removed", oldNo: num, newNo: null, text });
|
|
104
|
+
removed++;
|
|
105
|
+
delta--;
|
|
106
|
+
hasOriginal = true;
|
|
107
|
+
} else if (prefix === " ") {
|
|
108
|
+
current.push({ type: "context", oldNo: num, newNo: num + delta, text });
|
|
109
|
+
hasOriginal = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
flush();
|
|
113
|
+
|
|
114
|
+
if (hunks.length === 0) return null;
|
|
115
|
+
return { hunks, added, removed, isIdentical: added + removed === 0, isNewFile: !hasOriginal };
|
|
116
|
+
}
|
|
29
117
|
|
|
30
|
-
// ── Extension entry point ─────────────────────────────────────────
|
|
31
118
|
export default function activate(ctx: ExtensionContext): void {
|
|
32
|
-
const { bus } = ctx;
|
|
119
|
+
const { bus, call } = ctx;
|
|
33
120
|
const cwd = process.cwd();
|
|
34
121
|
|
|
35
|
-
// ── Boot pi session (async — register backend synchronously first) ──
|
|
36
122
|
let session: any = null;
|
|
37
123
|
let runtime: any = null;
|
|
124
|
+
let modelRegistry: any = null;
|
|
38
125
|
let booting = true;
|
|
39
126
|
|
|
127
|
+
const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
128
|
+
|
|
129
|
+
// Pi's tool_execution_end omits `args` — cache from start so the end handler can use the path.
|
|
130
|
+
const pendingArgs = new Map<string, any>();
|
|
131
|
+
// Snapshot disk content before pi writes; diffed against args.content at end.
|
|
132
|
+
const pendingWriteSnapshot = new Map<string, { oldContent: string; isNewFile: boolean }>();
|
|
133
|
+
|
|
40
134
|
const boot = async () => {
|
|
41
135
|
try {
|
|
42
|
-
// Pi loads its own config: ~/.pi/agent/settings.json, models, extensions
|
|
43
136
|
const services = await createAgentSessionServices({ cwd });
|
|
137
|
+
modelRegistry = services.modelRegistry;
|
|
44
138
|
const sessionManager = SessionManager.inMemory(cwd);
|
|
45
139
|
|
|
46
|
-
// createRuntime factory — returns { session, services, ... } as expected
|
|
47
|
-
// by createAgentSessionRuntime
|
|
48
140
|
const createRuntime = async (opts: any) => {
|
|
49
141
|
const result = await createAgentSessionFromServices({
|
|
50
142
|
services,
|
|
@@ -59,7 +151,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
59
151
|
});
|
|
60
152
|
session = runtime.session;
|
|
61
153
|
|
|
62
|
-
// Subscribe to pi events → agent-sh bus
|
|
63
154
|
let fullResponseText = "";
|
|
64
155
|
|
|
65
156
|
session.subscribe((event: AgentEvent) => {
|
|
@@ -81,13 +172,46 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
81
172
|
break;
|
|
82
173
|
}
|
|
83
174
|
|
|
84
|
-
case "
|
|
175
|
+
case "message_end": {
|
|
176
|
+
// Synthesize agent:tool-batch so tui-renderer groups parallel tool calls under one header.
|
|
177
|
+
const msg = (event as any).message;
|
|
178
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
179
|
+
const groupMap = new Map<string, Array<{ name: string }>>();
|
|
180
|
+
for (const block of msg.content) {
|
|
181
|
+
if (block?.type === "toolCall" && typeof block.name === "string") {
|
|
182
|
+
const kind = kindForTool(block.name);
|
|
183
|
+
if (!groupMap.has(kind)) groupMap.set(kind, []);
|
|
184
|
+
groupMap.get(kind)!.push({ name: block.name });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (groupMap.size > 0) {
|
|
188
|
+
const groups = Array.from(groupMap.entries()).map(([kind, tools]) => ({ kind, tools }));
|
|
189
|
+
bus.emit("agent:tool-batch", { groups });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case "tool_execution_start": {
|
|
196
|
+
const ev = event as any;
|
|
197
|
+
if (ev.toolCallId) pendingArgs.set(ev.toolCallId, ev.args);
|
|
198
|
+
if (ev.toolName === "write" && ev.toolCallId && typeof ev.args?.path === "string") {
|
|
199
|
+
const abs = resolvePath(cwd, ev.args.path);
|
|
200
|
+
let oldContent = "";
|
|
201
|
+
let isNewFile = true;
|
|
202
|
+
if (existsSync(abs)) {
|
|
203
|
+
try { oldContent = readFileSync(abs, "utf8"); isNewFile = false; } catch {}
|
|
204
|
+
}
|
|
205
|
+
pendingWriteSnapshot.set(ev.toolCallId, { oldContent, isNewFile });
|
|
206
|
+
}
|
|
85
207
|
bus.emit("agent:tool-started", {
|
|
86
|
-
title:
|
|
87
|
-
toolCallId:
|
|
88
|
-
kind: (
|
|
208
|
+
title: ev.toolName,
|
|
209
|
+
toolCallId: ev.toolCallId,
|
|
210
|
+
kind: kindForTool(ev.toolName),
|
|
211
|
+
rawInput: ev.args,
|
|
89
212
|
});
|
|
90
213
|
break;
|
|
214
|
+
}
|
|
91
215
|
|
|
92
216
|
case "tool_execution_update": {
|
|
93
217
|
const pr = (event as any).partialResult as
|
|
@@ -103,13 +227,41 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
103
227
|
break;
|
|
104
228
|
}
|
|
105
229
|
|
|
106
|
-
case "tool_execution_end":
|
|
230
|
+
case "tool_execution_end": {
|
|
231
|
+
const ev = event as any;
|
|
232
|
+
const args = ev.toolCallId ? pendingArgs.get(ev.toolCallId) : undefined;
|
|
233
|
+
if (ev.toolCallId) pendingArgs.delete(ev.toolCallId);
|
|
234
|
+
let resultDisplay: { body?: { kind: "diff"; diff: unknown; filePath: string } } | undefined;
|
|
235
|
+
if (ev.toolName === "edit" && typeof args?.path === "string") {
|
|
236
|
+
const rawDiff = ev.result?.details?.diff;
|
|
237
|
+
const parsed = parsePiDiff(rawDiff);
|
|
238
|
+
if (parsed) {
|
|
239
|
+
resultDisplay = {
|
|
240
|
+
body: { kind: "diff", diff: parsed, filePath: args.path },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
} else if (ev.toolName === "write" && typeof args?.path === "string" && !ev.isError) {
|
|
244
|
+
const snap = ev.toolCallId ? pendingWriteSnapshot.get(ev.toolCallId) : undefined;
|
|
245
|
+
if (ev.toolCallId) pendingWriteSnapshot.delete(ev.toolCallId);
|
|
246
|
+
if (snap) {
|
|
247
|
+
const newContent = typeof args.content === "string" ? args.content : "";
|
|
248
|
+
const built = buildDiffFromTexts(snap.oldContent, newContent, snap.isNewFile);
|
|
249
|
+
if (built) {
|
|
250
|
+
resultDisplay = {
|
|
251
|
+
body: { kind: "diff", diff: built, filePath: args.path },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
107
256
|
bus.emit("agent:tool-completed", {
|
|
108
|
-
toolCallId:
|
|
109
|
-
exitCode:
|
|
110
|
-
kind: (
|
|
257
|
+
toolCallId: ev.toolCallId,
|
|
258
|
+
exitCode: ev.isError ? 1 : 0,
|
|
259
|
+
kind: kindForTool(ev.toolName),
|
|
260
|
+
rawOutput: ev.result,
|
|
261
|
+
resultDisplay,
|
|
111
262
|
});
|
|
112
263
|
break;
|
|
264
|
+
}
|
|
113
265
|
|
|
114
266
|
case "agent_end":
|
|
115
267
|
bus.emitTransform("agent:response-done", {
|
|
@@ -120,7 +272,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
120
272
|
}
|
|
121
273
|
});
|
|
122
274
|
|
|
123
|
-
// Report model info
|
|
124
275
|
const model = session.model;
|
|
125
276
|
bus.emit("agent:info", {
|
|
126
277
|
name: "pi",
|
|
@@ -137,8 +288,10 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
137
288
|
}
|
|
138
289
|
};
|
|
139
290
|
|
|
140
|
-
|
|
141
|
-
|
|
291
|
+
type ListenerEntry =
|
|
292
|
+
| { kind: "on"; event: string; fn: Function }
|
|
293
|
+
| { kind: "pipe"; event: string; fn: Function };
|
|
294
|
+
const listeners: ListenerEntry[] = [];
|
|
142
295
|
|
|
143
296
|
const wireListeners = () => {
|
|
144
297
|
const onSubmit = async ({ query }: any) => {
|
|
@@ -153,8 +306,12 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
153
306
|
bus.emit("agent:query", { query });
|
|
154
307
|
bus.emit("agent:processing-start", {});
|
|
155
308
|
|
|
309
|
+
// Inline producers raw — outputs already self-tag (<shell_events>...).
|
|
310
|
+
const ctxText = String(call("query-context:build") ?? "").trim();
|
|
311
|
+
const final = ctxText ? `${ctxText}\n\n${query}` : query;
|
|
312
|
+
|
|
156
313
|
try {
|
|
157
|
-
await session.prompt(
|
|
314
|
+
await session.prompt(final);
|
|
158
315
|
} catch (err) {
|
|
159
316
|
bus.emit("agent:error", {
|
|
160
317
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -169,33 +326,148 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
169
326
|
session = runtime?.session;
|
|
170
327
|
};
|
|
171
328
|
|
|
329
|
+
const onListModels = () => {
|
|
330
|
+
if (!session || !modelRegistry) return { models: [], active: null };
|
|
331
|
+
const all = modelRegistry.getAvailable() as Array<{ id: string; provider: string }>;
|
|
332
|
+
const cur = session.model;
|
|
333
|
+
return {
|
|
334
|
+
models: all.map((m) => ({ model: m.id, provider: m.provider })),
|
|
335
|
+
active: cur ? { model: cur.id, provider: cur.provider } : null,
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Slash command emits `model@provider` for disambiguation; pi looks up by (provider, id).
|
|
340
|
+
const onSwitchModel = async ({ model: target }: { model: string }) => {
|
|
341
|
+
if (!session || !modelRegistry) return;
|
|
342
|
+
const atIdx = target.lastIndexOf("@");
|
|
343
|
+
const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
|
|
344
|
+
const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
|
|
345
|
+
|
|
346
|
+
const candidates = (modelRegistry.getAvailable() as Array<{ id: string; provider: string }>)
|
|
347
|
+
.filter((m) => m.id === modelId && (!providerHint || m.provider === providerHint));
|
|
348
|
+
|
|
349
|
+
if (candidates.length === 0) {
|
|
350
|
+
bus.emit("ui:error", { message: `Unknown model: ${target}` });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (candidates.length > 1) {
|
|
354
|
+
const opts = candidates.map((m) => `${m.id}@${m.provider}`).join(", ");
|
|
355
|
+
bus.emit("ui:error", { message: `Ambiguous model "${modelId}". Use one of: ${opts}` });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const picked = candidates[0]!;
|
|
359
|
+
const full = modelRegistry.find(picked.provider, picked.id);
|
|
360
|
+
if (!full) {
|
|
361
|
+
bus.emit("ui:error", { message: `Model not found: ${target}` });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
await session.setModel(full);
|
|
366
|
+
bus.emit("agent:info", {
|
|
367
|
+
name: "pi",
|
|
368
|
+
version: "0.66",
|
|
369
|
+
model: `${picked.provider}/${picked.id}`,
|
|
370
|
+
});
|
|
371
|
+
bus.emit("ui:info", { message: `Model: ${picked.provider}: ${picked.id}` });
|
|
372
|
+
bus.emit("config:changed", {});
|
|
373
|
+
} catch (err) {
|
|
374
|
+
bus.emit("ui:error", {
|
|
375
|
+
message: `Failed to switch model: ${err instanceof Error ? err.message : String(err)}`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const onGetThinking = () => {
|
|
381
|
+
const level = session?.thinkingLevel ?? "off";
|
|
382
|
+
return { level, levels: [...PI_THINKING_LEVELS], supported: true };
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const onSetThinking = ({ level }: { level: string }) => {
|
|
386
|
+
if (!session) return;
|
|
387
|
+
if (!PI_THINKING_LEVELS.includes(level as any)) {
|
|
388
|
+
bus.emit("ui:error", {
|
|
389
|
+
message: `Unknown thinking level: ${level}. Use: ${PI_THINKING_LEVELS.join(", ")}`,
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
session.setThinkingLevel(level);
|
|
394
|
+
bus.emit("ui:info", { message: `Thinking: ${level}` });
|
|
395
|
+
bus.emit("config:changed", {});
|
|
396
|
+
};
|
|
397
|
+
|
|
172
398
|
bus.on("agent:submit", onSubmit);
|
|
173
399
|
bus.on("agent:cancel-request", onCancel);
|
|
174
400
|
bus.on("agent:reset-session", onReset);
|
|
401
|
+
bus.on("config:switch-model", onSwitchModel as any);
|
|
402
|
+
bus.on("config:set-thinking", onSetThinking as any);
|
|
403
|
+
bus.onPipe("config:get-models", onListModels as any);
|
|
404
|
+
bus.onPipe("config:get-thinking", onGetThinking as any);
|
|
175
405
|
listeners.push(
|
|
176
|
-
{ event: "agent:submit", fn: onSubmit },
|
|
177
|
-
{ event: "agent:cancel-request", fn: onCancel },
|
|
178
|
-
{ event: "agent:reset-session", fn: onReset },
|
|
406
|
+
{ kind: "on", event: "agent:submit", fn: onSubmit },
|
|
407
|
+
{ kind: "on", event: "agent:cancel-request", fn: onCancel },
|
|
408
|
+
{ kind: "on", event: "agent:reset-session", fn: onReset },
|
|
409
|
+
{ kind: "on", event: "config:switch-model", fn: onSwitchModel },
|
|
410
|
+
{ kind: "on", event: "config:set-thinking", fn: onSetThinking },
|
|
411
|
+
{ kind: "pipe", event: "config:get-models", fn: onListModels },
|
|
412
|
+
{ kind: "pipe", event: "config:get-thinking", fn: onGetThinking },
|
|
179
413
|
);
|
|
180
414
|
};
|
|
181
415
|
|
|
182
416
|
const unwireListeners = () => {
|
|
183
|
-
for (const { event, fn } of listeners)
|
|
417
|
+
for (const { kind, event, fn } of listeners) {
|
|
418
|
+
if (kind === "pipe") bus.offPipe(event as any, fn as any);
|
|
419
|
+
else bus.off(event as any, fn as any);
|
|
420
|
+
}
|
|
184
421
|
listeners.length = 0;
|
|
185
422
|
};
|
|
186
423
|
|
|
187
|
-
// ── Register as backend ───────────────────────────────────────
|
|
188
424
|
bus.emit("agent:register-backend", {
|
|
189
425
|
name: "pi",
|
|
190
426
|
start: async () => {
|
|
191
427
|
await boot();
|
|
192
428
|
wireListeners();
|
|
429
|
+
bus.emit("command:register", {
|
|
430
|
+
name: "/compact",
|
|
431
|
+
description: "Compact pi's session context",
|
|
432
|
+
handler: async () => {
|
|
433
|
+
if (!session) return;
|
|
434
|
+
try {
|
|
435
|
+
await session.compact();
|
|
436
|
+
bus.emit("ui:info", { message: "(compacted)" });
|
|
437
|
+
} catch (err) {
|
|
438
|
+
bus.emit("ui:info", {
|
|
439
|
+
message: `(${err instanceof Error ? err.message : String(err)})`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
bus.emit("command:register", {
|
|
445
|
+
name: "/context",
|
|
446
|
+
description: "Show pi's context budget usage",
|
|
447
|
+
handler: () => {
|
|
448
|
+
if (!session) return;
|
|
449
|
+
const usage = session.getContextUsage() as { tokens: number; contextWindow: number } | undefined;
|
|
450
|
+
if (!usage) {
|
|
451
|
+
bus.emit("ui:info", { message: "Context: not available yet" });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const pct = usage.contextWindow > 0
|
|
455
|
+
? Math.round((usage.tokens / usage.contextWindow) * 100)
|
|
456
|
+
: 0;
|
|
457
|
+
bus.emit("ui:info", {
|
|
458
|
+
message: `Active context: ~${usage.tokens.toLocaleString()} tokens / ${usage.contextWindow.toLocaleString()} budget (${pct}%)`,
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
});
|
|
193
462
|
},
|
|
194
463
|
kill: () => {
|
|
464
|
+
bus.emit("command:unregister", { name: "/compact" });
|
|
465
|
+
bus.emit("command:unregister", { name: "/context" });
|
|
195
466
|
unwireListeners();
|
|
196
467
|
runtime?.dispose();
|
|
197
468
|
session = null;
|
|
198
469
|
runtime = null;
|
|
470
|
+
modelRegistry = null;
|
|
199
471
|
booting = true;
|
|
200
472
|
},
|
|
201
473
|
});
|
package/package.json
CHANGED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Built-in OpenAI-compatible provider. Two activation paths:
|
|
3
|
-
* - OPENAI_API_KEY only → cloud OpenAI, ships a curated catalog.
|
|
4
|
-
* - OPENAI_BASE_URL (any key) → local/3rd-party server (Ollama, LM Studio,
|
|
5
|
-
* vLLM, llama.cpp); the catalog is fetched
|
|
6
|
-
* from the server's /models endpoint.
|
|
7
|
-
*/
|
|
8
|
-
import type { ExtensionContext } from "../types.js";
|
|
9
|
-
export default function activate(ctx: ExtensionContext): void;
|