agent-sh 0.15.7 → 0.15.8
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/dist/agent/agent-loop.d.ts +3 -0
- package/dist/agent/agent-loop.js +17 -1
- package/dist/agent/events.d.ts +3 -0
- package/dist/agent/host-types.d.ts +6 -0
- package/dist/agent/index.js +5 -1
- package/dist/agent/llm-client.d.ts +2 -0
- package/dist/agent/llm-client.js +2 -2
- package/dist/agent/providers/openrouter.js +11 -1
- package/dist/shell/strategies/bash.js +10 -2
- package/examples/extensions/ashi/package.json +1 -1
- package/examples/extensions/ashi/src/chat/assistant.ts +3 -1
- package/examples/extensions/ashi/src/cli.ts +8 -0
- package/examples/extensions/ashi/src/frontend.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
- package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
- package/examples/extensions/latex-images.ts +152 -7
- package/package.json +1 -1
- package/src/agent/agent-loop.ts +17 -1
- package/src/agent/events.ts +1 -0
- package/src/agent/host-types.ts +2 -0
- package/src/agent/index.ts +7 -1
- package/src/agent/llm-client.ts +4 -2
- package/src/agent/providers/openrouter.ts +10 -1
- package/src/shell/strategies/bash.ts +10 -2
|
@@ -84,6 +84,9 @@ export declare class AgentLoop implements AgentBackend {
|
|
|
84
84
|
kill(): void;
|
|
85
85
|
private cancel;
|
|
86
86
|
private reasoningParams;
|
|
87
|
+
/** Resume-stable conversation id from the frontend (e.g. ashi); undefined
|
|
88
|
+
* when the frontend tracks no session. */
|
|
89
|
+
private currentSessionId;
|
|
87
90
|
private resolveEndpoint;
|
|
88
91
|
private pullModels;
|
|
89
92
|
private emitIdentity;
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -390,6 +390,17 @@ export class AgentLoop {
|
|
|
390
390
|
const effort = this.thinkingLevel === "xhigh" ? "high" : this.thinkingLevel;
|
|
391
391
|
return { reasoning_effort: effort };
|
|
392
392
|
}
|
|
393
|
+
/** Resume-stable conversation id from the frontend (e.g. ashi); undefined
|
|
394
|
+
* when the frontend tracks no session. */
|
|
395
|
+
currentSessionId() {
|
|
396
|
+
try {
|
|
397
|
+
const id = this.handlers.call("session:current-id");
|
|
398
|
+
return typeof id === "string" && id ? id : undefined;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
393
404
|
resolveEndpoint(m) {
|
|
394
405
|
try {
|
|
395
406
|
return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id });
|
|
@@ -1251,7 +1262,12 @@ export class AgentLoop {
|
|
|
1251
1262
|
...this.reasoningParams(),
|
|
1252
1263
|
};
|
|
1253
1264
|
this.bus.emit("llm:request", requestParams);
|
|
1254
|
-
const
|
|
1265
|
+
const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
|
|
1266
|
+
const stream = await this.llmClient.stream({
|
|
1267
|
+
...requestParams,
|
|
1268
|
+
signal,
|
|
1269
|
+
...(headers && Object.keys(headers).length ? { headers } : {}),
|
|
1270
|
+
});
|
|
1255
1271
|
try {
|
|
1256
1272
|
for await (const chunk of stream) {
|
|
1257
1273
|
if (signal.aborted)
|
package/dist/agent/events.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ declare module "../core/event-bus.js" {
|
|
|
21
21
|
id: string;
|
|
22
22
|
reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
|
|
23
23
|
cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
24
|
+
requestHeaders?: (info: {
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
}) => Record<string, string>;
|
|
24
27
|
};
|
|
25
28
|
"agent:models-changed": Record<string, never>;
|
|
26
29
|
"config:switch-provider": {
|
|
@@ -85,6 +85,9 @@ export interface ModelEndpoint {
|
|
|
85
85
|
baseURL?: string;
|
|
86
86
|
buildReasoningParams?: (level: string) => Record<string, unknown>;
|
|
87
87
|
extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
88
|
+
buildRequestHeaders?: (info: {
|
|
89
|
+
sessionId?: string;
|
|
90
|
+
}) => Record<string, string>;
|
|
88
91
|
}
|
|
89
92
|
/**
|
|
90
93
|
* Capabilities the agent host adds on top of CoreContext. Only available
|
|
@@ -100,6 +103,9 @@ export interface AgentSurface {
|
|
|
100
103
|
configure: (id: string, opts: {
|
|
101
104
|
reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
|
|
102
105
|
cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
106
|
+
requestHeaders?: (info: {
|
|
107
|
+
sessionId?: string;
|
|
108
|
+
}) => Record<string, string>;
|
|
103
109
|
}) => void;
|
|
104
110
|
};
|
|
105
111
|
registerTool: (tool: ToolDefinition) => void;
|
package/dist/agent/index.js
CHANGED
|
@@ -105,6 +105,7 @@ export default function agentBackend(ctx) {
|
|
|
105
105
|
return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
|
|
106
106
|
};
|
|
107
107
|
const bindCacheTokens = (shapeId) => providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
|
|
108
|
+
const bindRequestHeaders = (shapeId) => providerHooks.get(shapeId)?.requestHeaders;
|
|
108
109
|
const agentSurface = {
|
|
109
110
|
llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
|
|
110
111
|
providers: {
|
|
@@ -322,6 +323,7 @@ export default function agentBackend(ctx) {
|
|
|
322
323
|
baseURL: p.baseURL,
|
|
323
324
|
buildReasoningParams: bindReasoning(shapeId, modelId),
|
|
324
325
|
extractCachedTokens: bindCacheTokens(shapeId),
|
|
326
|
+
buildRequestHeaders: bindRequestHeaders(shapeId),
|
|
325
327
|
};
|
|
326
328
|
};
|
|
327
329
|
ctx.define("agent:get-models", () => buildModels());
|
|
@@ -365,12 +367,14 @@ export default function agentBackend(ctx) {
|
|
|
365
367
|
bus.emit("config:switch-model", { id: pendingModel, provider: pendingProvider });
|
|
366
368
|
}
|
|
367
369
|
});
|
|
368
|
-
bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
|
|
370
|
+
bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
|
|
369
371
|
const prev = providerHooks.get(id) ?? {};
|
|
370
372
|
if (reasoningParams !== undefined)
|
|
371
373
|
prev.reasoningParams = reasoningParams;
|
|
372
374
|
if (cacheTokens !== undefined)
|
|
373
375
|
prev.cacheTokens = cacheTokens;
|
|
376
|
+
if (requestHeaders !== undefined)
|
|
377
|
+
prev.requestHeaders = requestHeaders;
|
|
374
378
|
providerHooks.set(id, prev);
|
|
375
379
|
});
|
|
376
380
|
bus.on("core:extensions-loaded", ({ names }) => {
|
|
@@ -39,6 +39,8 @@ export type StreamOpts = {
|
|
|
39
39
|
model?: string;
|
|
40
40
|
max_tokens?: number;
|
|
41
41
|
signal?: AbortSignal;
|
|
42
|
+
/** Per-request transport headers, forwarded to the SDK (not request body). */
|
|
43
|
+
headers?: Record<string, string>;
|
|
42
44
|
} & Record<string, unknown>;
|
|
43
45
|
export type CompleteOpts = {
|
|
44
46
|
messages: ChatCompletionMessageParam[];
|
package/dist/agent/llm-client.js
CHANGED
|
@@ -43,7 +43,7 @@ export class LlmClient {
|
|
|
43
43
|
this.model = newConfig.model;
|
|
44
44
|
}
|
|
45
45
|
stream(opts) {
|
|
46
|
-
const { signal, messages, tools, model, max_tokens, ...rest } = opts;
|
|
46
|
+
const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
|
|
47
47
|
const body = {
|
|
48
48
|
...rest,
|
|
49
49
|
model: model ?? this.model,
|
|
@@ -53,7 +53,7 @@ export class LlmClient {
|
|
|
53
53
|
stream: true,
|
|
54
54
|
stream_options: { include_usage: true },
|
|
55
55
|
};
|
|
56
|
-
return this.client.chat.completions.create(body, { signal });
|
|
56
|
+
return this.client.chat.completions.create(body, { signal, headers });
|
|
57
57
|
}
|
|
58
58
|
async complete(opts) {
|
|
59
59
|
const { messages, model, max_tokens, ...rest } = opts;
|
|
@@ -24,7 +24,17 @@ function toModalities(input) {
|
|
|
24
24
|
}
|
|
25
25
|
export default function activate(ctx) {
|
|
26
26
|
const apiKey = resolveApiKey("openrouter").key;
|
|
27
|
-
ctx.agent.providers.configure("openrouter", {
|
|
27
|
+
ctx.agent.providers.configure("openrouter", {
|
|
28
|
+
reasoningParams: buildReasoningParams,
|
|
29
|
+
// x-session-id pins sticky provider routing across turns so prompt caches
|
|
30
|
+
// stay warm even when compaction rewrites the opening messages.
|
|
31
|
+
requestHeaders: ({ sessionId }) => {
|
|
32
|
+
const headers = {};
|
|
33
|
+
if (sessionId)
|
|
34
|
+
headers["x-session-id"] = sessionId;
|
|
35
|
+
return headers;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
28
38
|
ctx.agent.providers.register({
|
|
29
39
|
id: "openrouter",
|
|
30
40
|
apiKey: apiKey ?? undefined,
|
|
@@ -40,8 +40,16 @@ export const bashStrategy = {
|
|
|
40
40
|
' [[ $__agent_sh_preexec_ran == 1 ]] && return',
|
|
41
41
|
' [[ -n $COMP_LINE ]] && return',
|
|
42
42
|
" __agent_sh_preexec_ran=1",
|
|
43
|
-
" local this_cmd",
|
|
44
|
-
`
|
|
43
|
+
" local this_cmd hist_cmd",
|
|
44
|
+
` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
|
|
45
|
+
" # history 1 carries the full typed line but goes stale when the user's",
|
|
46
|
+
" # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
|
|
47
|
+
" # matches the command bash is about to run; else use $BASH_COMMAND.",
|
|
48
|
+
' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
|
|
49
|
+
" this_cmd=$hist_cmd",
|
|
50
|
+
" else",
|
|
51
|
+
" this_cmd=$BASH_COMMAND",
|
|
52
|
+
" fi",
|
|
45
53
|
` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
|
|
46
54
|
"}",
|
|
47
55
|
"trap '__agent_sh_emit_preexec' DEBUG",
|
|
@@ -38,7 +38,9 @@ export class AssistantMessage {
|
|
|
38
38
|
if (this.buffer === "") this.buffer = " ";
|
|
39
39
|
const blocks = this.transform([{ type: "text", text: this.buffer }]);
|
|
40
40
|
if (blocks.every((b) => b.type === "text")) {
|
|
41
|
-
|
|
41
|
+
// Render the transformed text, not the raw buffer — transforms may rewrite
|
|
42
|
+
// content in place (e.g. inline-image sentinels) while staying all-text.
|
|
43
|
+
this.md.setText(stripTrailing(blocks.map((b) => (b.type === "text" ? b.text : "")).join("")));
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
this.rebuild(blocks);
|
|
@@ -34,6 +34,7 @@ import { registerCapture, type Capture } from "./capture.js";
|
|
|
34
34
|
import { registerRenderDefaults } from "./hooks.js";
|
|
35
35
|
import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
|
|
36
36
|
import { createPiTuiRenderer } from "./renderers/pi-tui/index.js";
|
|
37
|
+
import { registerInlineImage, supportsInlineImages } from "./renderers/pi-tui/inline-image.js";
|
|
37
38
|
import type { Renderer } from "./renderer.js";
|
|
38
39
|
import { loadRendererPreference } from "./display-config.js";
|
|
39
40
|
import { applyOutputMode } from "./terminal-mode.js";
|
|
@@ -177,6 +178,8 @@ async function main(): Promise<void> {
|
|
|
177
178
|
|
|
178
179
|
const ctx = core.extensionContext({ quit: cleanup });
|
|
179
180
|
|
|
181
|
+
ctx.define("session:current-id", () => store.current().id);
|
|
182
|
+
|
|
180
183
|
activateAgent(ctx);
|
|
181
184
|
activateShellContext(ctx);
|
|
182
185
|
await loadBuiltinExtensions(ctx);
|
|
@@ -238,6 +241,11 @@ async function main(): Promise<void> {
|
|
|
238
241
|
registerRenderDefaults(ctx, renderer);
|
|
239
242
|
registerDefaultSchemaRenderers(ctx);
|
|
240
243
|
|
|
244
|
+
// Handler presence is how producers detect that inline images are available.
|
|
245
|
+
if (rendererName === "pi-tui" && supportsInlineImages()) {
|
|
246
|
+
ctx.define("ashi:inline-image:register", (png: Buffer) => registerInlineImage(png));
|
|
247
|
+
}
|
|
248
|
+
|
|
241
249
|
ctx.advise("system-prompt:frontend", (next) => {
|
|
242
250
|
const base = (next() as string) ?? "";
|
|
243
251
|
return base ? `${base}\n\n${ASHI_SURFACE}` : ASHI_SURFACE;
|
|
@@ -399,7 +399,7 @@ export function mountAshi(
|
|
|
399
399
|
app.requestRender();
|
|
400
400
|
return;
|
|
401
401
|
}
|
|
402
|
-
pendingUserShell.push({ private: !!opts?.private });
|
|
402
|
+
pendingUserShell.push({ private: !!opts?.private, command: line });
|
|
403
403
|
if (opts?.private) bus.emit("shell:user-exec-exclude-next", {});
|
|
404
404
|
bus.emit("shell:pty-write", { data: line + "\n" });
|
|
405
405
|
};
|
|
@@ -816,12 +816,13 @@ export function mountAshi(
|
|
|
816
816
|
bus.on("shell:foreground-busy", ({ busy }) => { shellForegroundBusy = busy; });
|
|
817
817
|
|
|
818
818
|
let activeUserShell: { pair: ToolPair; command: string; isPrivate: boolean } | null = null;
|
|
819
|
-
bus.on("shell:command-start", (
|
|
819
|
+
bus.on("shell:command-start", () => {
|
|
820
820
|
if (agentShellActive) return;
|
|
821
821
|
const intent = pendingUserShell.consume();
|
|
822
822
|
if (!intent) return;
|
|
823
823
|
finalizeThinking();
|
|
824
824
|
if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
|
|
825
|
+
const command = intent.command;
|
|
825
826
|
const isPrivate = intent.private;
|
|
826
827
|
const name = isPrivate ? "user_bash_private" : "user_bash";
|
|
827
828
|
const pair = renderToolPair({
|
|
@@ -861,7 +862,7 @@ export function mountAshi(
|
|
|
861
862
|
// Drain shell queue before queries so its output lands in the next turn's <shell_events>.
|
|
862
863
|
while (queuedShellLines.length > 0) {
|
|
863
864
|
const item = queuedShellLines.shift()!;
|
|
864
|
-
pendingUserShell.push({ private: item.private });
|
|
865
|
+
pendingUserShell.push({ private: item.private, command: item.line });
|
|
865
866
|
if (item.private) bus.emit("shell:user-exec-exclude-next", {});
|
|
866
867
|
bus.emit("shell:pty-write", { data: item.line + "\n" });
|
|
867
868
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline images in markdown text via the kitty/Ghostty Unicode placeholder
|
|
3
|
+
* protocol. The image is transmitted out-of-band (cursor-neutral); the frame
|
|
4
|
+
* carries only `cols` width-1 placeholder cells, so the renderer's wrap/measure
|
|
5
|
+
* math is unaffected and the line is never treated as an image line. kitty-only.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
allocateImageId,
|
|
9
|
+
getCapabilities,
|
|
10
|
+
getCellDimensions,
|
|
11
|
+
getImageDimensions,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
export const SENTINEL_RE = /\x01LI:(\d+)\x01/g;
|
|
15
|
+
export const PLACEHOLDER = String.fromCodePoint(0x10eeee);
|
|
16
|
+
|
|
17
|
+
// kitty rowcolumn-diacritics: the Nth entry encodes row/column index N
|
|
18
|
+
// (gen/rowcolumn-diacritics.txt in the kitty source).
|
|
19
|
+
const DIACRITICS = [0x0305,0x030D,0x030E,0x0310,0x0312,0x033D,0x033E,0x033F,0x0346,0x034A,0x034B,0x034C,0x0350,0x0351,0x0352,0x0357,0x035B,0x0363,0x0364,0x0365,0x0366,0x0367,0x0368,0x0369,0x036A,0x036B,0x036C,0x036D,0x036E,0x036F,0x0483,0x0484,0x0485,0x0486,0x0487,0x0592,0x0593,0x0594,0x0595,0x0597,0x0598,0x0599,0x059C,0x059D,0x059E,0x059F,0x05A0,0x05A1,0x05A8,0x05A9,0x05AB,0x05AC,0x05AF,0x05C4,0x0610,0x0611,0x0612,0x0613,0x0614,0x0615,0x0616,0x0617,0x0657,0x0658,0x0659,0x065A,0x065B,0x065D,0x065E,0x06D6,0x06D7,0x06D8,0x06D9,0x06DA,0x06DB,0x06DC,0x06DF,0x06E0,0x06E1,0x06E2,0x06E4,0x06E7,0x06E8,0x06EB,0x06EC,0x0730,0x0732,0x0733,0x0735,0x0736,0x073A,0x073D,0x073F,0x0740,0x0741,0x0743,0x0745,0x0747,0x0749,0x074A,0x07EB,0x07EC,0x07ED,0x07EE,0x07EF,0x07F0,0x07F1,0x07F3,0x0816,0x0817,0x0818,0x0819,0x081B,0x081C,0x081D,0x081E,0x081F,0x0820,0x0821,0x0822,0x0823,0x0825,0x0826,0x0827,0x0829,0x082A,0x082B,0x082C,0x082D,0x0951,0x0953,0x0954,0x0F82,0x0F83,0x0F86,0x0F87,0x135D,0x135E,0x135F,0x17DD,0x193A,0x1A17,0x1A75,0x1A76,0x1A77,0x1A78,0x1A79,0x1A7A,0x1A7B,0x1A7C,0x1B6B,0x1B6D,0x1B6E,0x1B6F,0x1B70,0x1B71,0x1B72,0x1B73,0x1CD0,0x1CD1,0x1CD2,0x1CDA,0x1CDB,0x1CE0,0x1DC0,0x1DC1,0x1DC3,0x1DC4,0x1DC5,0x1DC6,0x1DC7,0x1DC8,0x1DC9,0x1DCB,0x1DCC,0x1DD1,0x1DD2,0x1DD3,0x1DD4,0x1DD5,0x1DD6,0x1DD7,0x1DD8,0x1DD9,0x1DDA,0x1DDB,0x1DDC,0x1DDD,0x1DDE,0x1DDF,0x1DE0,0x1DE1,0x1DE2,0x1DE3,0x1DE4,0x1DE5,0x1DE6,0x1DFE,0x20D0,0x20D1,0x20D4,0x20D5,0x20D6,0x20D7,0x20DB,0x20DC,0x20E1,0x20E7,0x20E9,0x20F0,0x2CEF,0x2CF0,0x2CF1,0x2DE0,0x2DE1,0x2DE2,0x2DE3,0x2DE4,0x2DE5,0x2DE6,0x2DE7,0x2DE8,0x2DE9,0x2DEA,0x2DEB,0x2DEC,0x2DED,0x2DEE,0x2DEF,0x2DF0,0x2DF1,0x2DF2,0x2DF3,0x2DF4,0x2DF5,0x2DF6,0x2DF7,0x2DF8,0x2DF9,0x2DFA,0x2DFB,0x2DFC,0x2DFD,0x2DFE,0x2DFF,0xA66F,0xA67C,0xA67D,0xA6F0,0xA6F1,0xA8E0,0xA8E1,0xA8E2,0xA8E3,0xA8E4,0xA8E5,0xA8E6,0xA8E7,0xA8E8,0xA8E9,0xA8EA,0xA8EB,0xA8EC,0xA8ED,0xA8EE,0xA8EF,0xA8F0,0xA8F1,0xAAB0,0xAAB2,0xAAB3,0xAAB7,0xAAB8,0xAABE,0xAABF,0xAAC1,0xFE20,0xFE21,0xFE22,0xFE23,0xFE24,0xFE25,0xFE26,0x10A0F,0x10A38,0x1D185,0x1D186,0x1D187,0x1D188,0x1D189,0x1D1AA,0x1D1AB,0x1D1AC,0x1D1AD,0x1D242,0x1D243,0x1D244];
|
|
20
|
+
|
|
21
|
+
const ESC = "\x1b";
|
|
22
|
+
const diacritic = (i: number): string =>
|
|
23
|
+
String.fromCodePoint(DIACRITICS[Math.min(i, DIACRITICS.length - 1)]!);
|
|
24
|
+
|
|
25
|
+
interface Entry {
|
|
26
|
+
base64: string;
|
|
27
|
+
widthPx: number;
|
|
28
|
+
heightPx: number;
|
|
29
|
+
transmitted: boolean;
|
|
30
|
+
placedCols: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registry = new Map<number, Entry>();
|
|
34
|
+
|
|
35
|
+
export function supportsInlineImages(): boolean {
|
|
36
|
+
return getCapabilities().images === "kitty";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerInlineImage(png: Buffer): number | null {
|
|
40
|
+
if (!supportsInlineImages()) return null;
|
|
41
|
+
const base64 = png.toString("base64");
|
|
42
|
+
const dims = getImageDimensions(base64, "image/png");
|
|
43
|
+
if (!dims || dims.heightPx <= 0) return null;
|
|
44
|
+
const id = allocateImageId();
|
|
45
|
+
registry.set(id, { base64, widthPx: dims.widthPx, heightPx: dims.heightPx, transmitted: false, placedCols: 0 });
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const maxInlineCols = (): number => Math.max(8, (process.stdout.columns ?? 80) - 4);
|
|
50
|
+
|
|
51
|
+
export function inlineCols(id: number, maxCols = maxInlineCols()): number | null {
|
|
52
|
+
const e = registry.get(id);
|
|
53
|
+
if (!e) return null;
|
|
54
|
+
const cell = getCellDimensions();
|
|
55
|
+
const cols = Math.round((e.widthPx / e.heightPx) * (cell.heightPx / cell.widthPx));
|
|
56
|
+
// Column index is carried by a diacritic, so cols can't exceed the table.
|
|
57
|
+
return Math.max(1, Math.min(maxCols, DIACRITICS.length, cols));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Transmits once, then re-places only when cols changes (resize). `write` must
|
|
61
|
+
// target the same stream as the composed frame so the data lands before the cells.
|
|
62
|
+
export function emitInlineImage(id: number, cols: number, write: (s: string) => void): void {
|
|
63
|
+
const e = registry.get(id);
|
|
64
|
+
if (!e) return;
|
|
65
|
+
if (!e.transmitted) {
|
|
66
|
+
e.transmitted = true;
|
|
67
|
+
e.placedCols = cols;
|
|
68
|
+
const b64 = e.base64;
|
|
69
|
+
const CHUNK = 4096;
|
|
70
|
+
for (let i = 0; i < b64.length; i += CHUNK) {
|
|
71
|
+
const chunk = b64.slice(i, i + CHUNK);
|
|
72
|
+
const last = i + CHUNK >= b64.length;
|
|
73
|
+
write(
|
|
74
|
+
i === 0
|
|
75
|
+
? `${ESC}_Gq=2,a=T,U=1,f=100,t=d,i=${id},c=${cols},r=1,m=${last ? 0 : 1};${chunk}${ESC}\\`
|
|
76
|
+
: `${ESC}_Gq=2,m=${last ? 0 : 1};${chunk}${ESC}\\`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
} else if (e.placedCols !== cols) {
|
|
80
|
+
e.placedCols = cols;
|
|
81
|
+
write(`${ESC}_Gq=2,a=p,U=1,i=${id},c=${cols},r=1${ESC}\\`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Each cell encodes the id's low 24 bits in the fg colour and its high byte in the
|
|
86
|
+
// third diacritic (row, column, id-high) — allocateImageId() ids use all 32 bits.
|
|
87
|
+
// `colStart` offsets the column index so an image split across lines (wrap) keeps
|
|
88
|
+
// continuous columns instead of repeating its left edge.
|
|
89
|
+
export function inlinePlaceholder(id: number, count: number, colStart = 0): string {
|
|
90
|
+
const hi = diacritic((id >> 24) & 255);
|
|
91
|
+
let s = `${ESC}[38;2;${(id >> 16) & 255};${(id >> 8) & 255};${id & 255}m`;
|
|
92
|
+
for (let j = 0; j < count; j++) s += PLACEHOLDER + diacritic(0) + diacritic(colStart + j) + hi;
|
|
93
|
+
return s + `${ESC}[39m`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface InlineItem {
|
|
97
|
+
id: number;
|
|
98
|
+
cols: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Repaint the reserved PLACEHOLDER runs as real kitty placeholder cells. Cells are
|
|
102
|
+
// partitioned by each image's reserved `cols` rather than by contiguous run, so
|
|
103
|
+
// adjacent images (`$a$$b$` → one fused run) and an image the wrapper split across
|
|
104
|
+
// lines both keep their ids aligned. `transmit` fires once per image, when its
|
|
105
|
+
// first cell is placed. Pure apart from the injected `transmit` callback.
|
|
106
|
+
export function paintInlineImages(
|
|
107
|
+
lines: string[],
|
|
108
|
+
items: InlineItem[],
|
|
109
|
+
transmit: (id: number, cols: number) => void,
|
|
110
|
+
): string[] {
|
|
111
|
+
if (items.length === 0) return lines;
|
|
112
|
+
let k = 0; // index of the image currently being placed
|
|
113
|
+
let placed = 0; // cells of image k already emitted across prior lines
|
|
114
|
+
return lines.map((line) => {
|
|
115
|
+
if (k >= items.length || !line.includes(PLACEHOLDER)) return line;
|
|
116
|
+
let out = "";
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < line.length) {
|
|
119
|
+
const ch = String.fromCodePoint(line.codePointAt(i)!);
|
|
120
|
+
if (ch === PLACEHOLDER && k < items.length) {
|
|
121
|
+
const item = items[k]!;
|
|
122
|
+
let count = 0;
|
|
123
|
+
while (
|
|
124
|
+
i < line.length &&
|
|
125
|
+
String.fromCodePoint(line.codePointAt(i)!) === PLACEHOLDER &&
|
|
126
|
+
placed + count < item.cols
|
|
127
|
+
) {
|
|
128
|
+
count++;
|
|
129
|
+
i += PLACEHOLDER.length;
|
|
130
|
+
}
|
|
131
|
+
if (placed === 0) transmit(item.id, item.cols);
|
|
132
|
+
out += inlinePlaceholder(item.id, count, placed);
|
|
133
|
+
placed += count;
|
|
134
|
+
if (placed >= item.cols) {
|
|
135
|
+
k++;
|
|
136
|
+
placed = 0;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
out += ch;
|
|
140
|
+
i += ch.length;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -14,6 +14,14 @@ import {
|
|
|
14
14
|
} from "@earendil-works/pi-tui";
|
|
15
15
|
import { theme } from "../../theme.js";
|
|
16
16
|
import { markdownTheme } from "./theme-adapters.js";
|
|
17
|
+
import {
|
|
18
|
+
emitInlineImage,
|
|
19
|
+
inlineCols,
|
|
20
|
+
paintInlineImages,
|
|
21
|
+
PLACEHOLDER,
|
|
22
|
+
SENTINEL_RE,
|
|
23
|
+
type InlineItem,
|
|
24
|
+
} from "./inline-image.js";
|
|
17
25
|
import type {
|
|
18
26
|
ContainerView,
|
|
19
27
|
MarkdownOptions,
|
|
@@ -118,6 +126,48 @@ class ZonedMarkdown extends Markdown {
|
|
|
118
126
|
}
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
// Each sentinel becomes a bare run of `cols` placeholder cells so the wrapper
|
|
130
|
+
// reserves the right width; render() later paints the image into that run.
|
|
131
|
+
// Exported for tests: with no sentinels (latex-images off / non-kitty terminal,
|
|
132
|
+
// where the sentinel producer never runs) this is a pass-through.
|
|
133
|
+
export function reserveSentinels(full: string): { display: string; items: InlineItem[] } {
|
|
134
|
+
const items: InlineItem[] = [];
|
|
135
|
+
const display = full.replace(SENTINEL_RE, (_m, idStr) => {
|
|
136
|
+
const cols = inlineCols(Number(idStr));
|
|
137
|
+
if (cols === null) return "";
|
|
138
|
+
items.push({ id: Number(idStr), cols });
|
|
139
|
+
return PLACEHOLDER.repeat(cols);
|
|
140
|
+
});
|
|
141
|
+
return { display, items };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function injectInlineImages(lines: string[], items: InlineItem[]): string[] {
|
|
145
|
+
const write = (s: string): boolean => process.stdout.write(s);
|
|
146
|
+
return paintInlineImages(lines, items, (id, cols) => emitInlineImage(id, cols, write));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type MarkdownCtor = new (...args: ConstructorParameters<typeof Markdown>) => Markdown;
|
|
150
|
+
|
|
151
|
+
// Subclass that flows inline images where the text carries sentinels; a no-op
|
|
152
|
+
// superset of the base when none are present.
|
|
153
|
+
function withInlineImages<T extends MarkdownCtor>(Base: T): T {
|
|
154
|
+
class InlineImageMarkdown extends (Base as MarkdownCtor) {
|
|
155
|
+
private inlineItems: InlineItem[] = [];
|
|
156
|
+
override setText(full: string): void {
|
|
157
|
+
const { display, items } = reserveSentinels(full);
|
|
158
|
+
this.inlineItems = items;
|
|
159
|
+
super.setText(display);
|
|
160
|
+
}
|
|
161
|
+
override render(width: number): string[] {
|
|
162
|
+
return injectInlineImages(super.render(width), this.inlineItems);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return InlineImageMarkdown as unknown as T;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const InlineMarkdown = withInlineImages(Markdown);
|
|
169
|
+
const InlineZonedMarkdown = withInlineImages(ZonedMarkdown);
|
|
170
|
+
|
|
121
171
|
class FooterSlot extends Container {
|
|
122
172
|
constructor(private readonly hasContentAbove: () => boolean) {
|
|
123
173
|
super();
|
|
@@ -157,7 +207,7 @@ export function createNodes(opts: { imageScale?: number } = {}): RenderNodes {
|
|
|
157
207
|
opts?.color || opts?.bgColor
|
|
158
208
|
? { ...(opts.color ? { color: opts.color } : {}), ...(opts.bgColor ? { bgColor: opts.bgColor } : {}) }
|
|
159
209
|
: undefined;
|
|
160
|
-
const Ctor = opts?.osc133Zones ?
|
|
210
|
+
const Ctor = opts?.osc133Zones ? InlineZonedMarkdown : InlineMarkdown;
|
|
161
211
|
const md = new Ctor("", opts?.paddingX ?? 0, opts?.paddingY ?? 0, markdownTheme(), colorOpts);
|
|
162
212
|
const view: MarkdownView = {
|
|
163
213
|
node: asNode(md),
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/** Pending intents for ashi-issued shell pty-writes. shell:command-start fires
|
|
2
|
-
* for any OSC 9997 — orphans (bash DEBUG-trap noise) are dropped on consume.
|
|
2
|
+
* for any OSC 9997 — orphans (bash DEBUG-trap noise) are dropped on consume.
|
|
3
|
+
* Carries the command ashi sent so the rendered label is the exact text we
|
|
4
|
+
* wrote to the pty, not whatever the shell echoes back. */
|
|
3
5
|
export interface UserShellIntent {
|
|
4
6
|
private: boolean;
|
|
7
|
+
command: string;
|
|
5
8
|
}
|
|
6
9
|
|
|
7
10
|
export class UserShellIntents {
|
|
@@ -22,7 +22,10 @@ import * as path from "node:path";
|
|
|
22
22
|
import type { ExtensionContext } from "agent-sh/types";
|
|
23
23
|
|
|
24
24
|
// Settings loaded in activate() via ctx.getExtensionSettings
|
|
25
|
-
|
|
25
|
+
// inlineScale: inline math font vs ~1 em of text (1.0 ≈ text, <1 smaller).
|
|
26
|
+
let config = { dpi: 300, fgColor: "d4d4d4", inlineScale: 1.0 };
|
|
27
|
+
|
|
28
|
+
let magickBin: string | null = null;
|
|
26
29
|
|
|
27
30
|
/** Encode PNG as iTerm2 or Kitty inline image escape sequence. */
|
|
28
31
|
function encodeImage(data: Buffer): string {
|
|
@@ -56,6 +59,17 @@ $\\displaystyle ${equation}$
|
|
|
56
59
|
\\end{document}
|
|
57
60
|
`;
|
|
58
61
|
|
|
62
|
+
// Inline equations: text-style (no \displaystyle) so sizing matches a text line.
|
|
63
|
+
const LATEX_INLINE_TEMPLATE = (equation: string, fg: string) => `
|
|
64
|
+
\\documentclass[border=1pt]{standalone}
|
|
65
|
+
\\usepackage{amsmath,amssymb,amsfonts}
|
|
66
|
+
\\usepackage{xcolor}
|
|
67
|
+
\\begin{document}
|
|
68
|
+
\\color[HTML]{${fg}}
|
|
69
|
+
$ ${equation} $
|
|
70
|
+
\\end{document}
|
|
71
|
+
`;
|
|
72
|
+
|
|
59
73
|
let tmpDir: string | null = null;
|
|
60
74
|
let renderCounter = 0;
|
|
61
75
|
|
|
@@ -66,7 +80,10 @@ function ensureTmpDir(): string {
|
|
|
66
80
|
return tmpDir;
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
function renderEquation(
|
|
83
|
+
function renderEquation(
|
|
84
|
+
equation: string,
|
|
85
|
+
template: (eq: string, fg: string) => string,
|
|
86
|
+
): Buffer | null {
|
|
70
87
|
const dir = ensureTmpDir();
|
|
71
88
|
const idx = renderCounter++;
|
|
72
89
|
const texPath = path.join(dir, `eq${idx}.tex`);
|
|
@@ -74,7 +91,7 @@ function renderEquation(equation: string): Buffer | null {
|
|
|
74
91
|
const pngPath = path.join(dir, `eq${idx}.png`);
|
|
75
92
|
|
|
76
93
|
try {
|
|
77
|
-
fs.writeFileSync(texPath,
|
|
94
|
+
fs.writeFileSync(texPath, template(equation, config.fgColor));
|
|
78
95
|
|
|
79
96
|
execSync(
|
|
80
97
|
`latex -interaction=nonstopmode -output-directory="${dir}" "${texPath}"`,
|
|
@@ -98,10 +115,50 @@ function renderEquation(equation: string): Buffer | null {
|
|
|
98
115
|
|
|
99
116
|
const equationCache = new Map<string, Buffer | null>();
|
|
100
117
|
function renderEquationCached(equation: string): Buffer | null {
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
const key = `d:${equation}`;
|
|
119
|
+
if (!equationCache.has(key)) {
|
|
120
|
+
equationCache.set(key, renderEquation(equation, LATEX_TEMPLATE));
|
|
121
|
+
}
|
|
122
|
+
return equationCache.get(key) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 1 TeX pt = 1/72.27 inch; standalone's default body font is 10pt.
|
|
126
|
+
const PT_PER_INCH = 72.27;
|
|
127
|
+
|
|
128
|
+
// Pad each equation's height up to a shared ~1 em reference (transparent, centered)
|
|
129
|
+
// so short and tall expressions render at the same font — only their heights differ.
|
|
130
|
+
// Cols are derived downstream from this padded height. No-op for already-tall content.
|
|
131
|
+
function normalizeInlineHeight(buf: Buffer): Buffer {
|
|
132
|
+
if (!magickBin) return buf;
|
|
133
|
+
const w = buf.readUInt32BE(16);
|
|
134
|
+
const h = buf.readUInt32BE(20);
|
|
135
|
+
const emPx = (config.dpi * 10) / PT_PER_INCH;
|
|
136
|
+
const scale = config.inlineScale > 0 ? config.inlineScale : 1;
|
|
137
|
+
const targetH = Math.round(emPx / scale);
|
|
138
|
+
if (h >= targetH) return buf;
|
|
139
|
+
const dir = ensureTmpDir();
|
|
140
|
+
const idx = renderCounter++;
|
|
141
|
+
const inPath = path.join(dir, `pad${idx}-in.png`);
|
|
142
|
+
const outPath = path.join(dir, `pad${idx}-out.png`);
|
|
143
|
+
try {
|
|
144
|
+
fs.writeFileSync(inPath, buf);
|
|
145
|
+
execSync(
|
|
146
|
+
`${magickBin} "${inPath}" -background none -gravity center -extent ${w}x${targetH} "${outPath}"`,
|
|
147
|
+
{ timeout: 10000, stdio: "pipe" },
|
|
148
|
+
);
|
|
149
|
+
return fs.readFileSync(outPath);
|
|
150
|
+
} catch {
|
|
151
|
+
return buf;
|
|
103
152
|
}
|
|
104
|
-
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderInlineCached(equation: string): Buffer | null {
|
|
156
|
+
const key = `i:${equation}`;
|
|
157
|
+
if (!equationCache.has(key)) {
|
|
158
|
+
const png = renderEquation(equation, LATEX_INLINE_TEMPLATE);
|
|
159
|
+
equationCache.set(key, png ? normalizeInlineHeight(png) : null);
|
|
160
|
+
}
|
|
161
|
+
return equationCache.get(key) ?? null;
|
|
105
162
|
}
|
|
106
163
|
|
|
107
164
|
const EQ_DELIM = "$$";
|
|
@@ -132,6 +189,65 @@ function splitEquations(text: string): Block[] {
|
|
|
132
189
|
return out;
|
|
133
190
|
}
|
|
134
191
|
|
|
192
|
+
// ── Inline math ($…$) ────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
// KaTeX-style `$…$` rules so prose/currency don't false-match: no space after the
|
|
195
|
+
// opening `$`; no space before the closing `$` and no digit after it; one line; \$.
|
|
196
|
+
export function matchInline(text: string, open: number): { eq: string; end: number } | null {
|
|
197
|
+
if (open + 1 >= text.length || /\s/.test(text[open + 1]!)) return null;
|
|
198
|
+
for (let j = open + 1; j < text.length; j++) {
|
|
199
|
+
const ch = text[j]!;
|
|
200
|
+
if (ch === "\n") return null;
|
|
201
|
+
if (ch === "\\") { j++; continue; }
|
|
202
|
+
if (ch !== "$") continue;
|
|
203
|
+
if (/\s/.test(text[j - 1]!)) continue;
|
|
204
|
+
if (/[0-9]/.test(text[j + 1] ?? "")) continue;
|
|
205
|
+
const eq = text.slice(open + 1, j);
|
|
206
|
+
return eq.trim() === "" ? null : { eq, end: j + 1 };
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Replace inline `$…$` with `\x01LI:<id>\x01` sentinels; leaves escapes and inline
|
|
212
|
+
// code spans untouched, and falls back to literal text when register() returns null.
|
|
213
|
+
export function replaceInline(text: string, register: (eq: string) => number | null): string {
|
|
214
|
+
let out = "";
|
|
215
|
+
let i = 0;
|
|
216
|
+
while (i < text.length) {
|
|
217
|
+
const c = text[i]!;
|
|
218
|
+
if (c === "\\") { out += text.slice(i, i + 2); i += 2; continue; }
|
|
219
|
+
if (c === "`") {
|
|
220
|
+
// Code span: a run of N backticks closes on the next run of exactly N.
|
|
221
|
+
// An unmatched opening run is a literal backtick — keep scanning after it.
|
|
222
|
+
let n = 1;
|
|
223
|
+
while (text[i + n] === "`") n++;
|
|
224
|
+
let j = i + n;
|
|
225
|
+
let close = -1;
|
|
226
|
+
while (j < text.length) {
|
|
227
|
+
if (text[j] === "`") {
|
|
228
|
+
let m = 1;
|
|
229
|
+
while (text[j + m] === "`") m++;
|
|
230
|
+
if (m === n) { close = j; break; }
|
|
231
|
+
j += m;
|
|
232
|
+
} else j++;
|
|
233
|
+
}
|
|
234
|
+
if (close === -1) { out += text.slice(i, i + n); i += n; continue; }
|
|
235
|
+
out += text.slice(i, close + n); i = close + n; continue;
|
|
236
|
+
}
|
|
237
|
+
if (c === "$" && text[i + 1] !== "$") {
|
|
238
|
+
const m = matchInline(text, i);
|
|
239
|
+
if (m) {
|
|
240
|
+
const id = register(m.eq);
|
|
241
|
+
out += id === null ? text.slice(i, m.end) : `\x01LI:${id}\x01`;
|
|
242
|
+
i = m.end;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
out += c; i++;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
135
251
|
// ── Extension entry point ────────────────────────────────────────
|
|
136
252
|
|
|
137
253
|
export default function activate(ctx: ExtensionContext) {
|
|
@@ -151,6 +267,15 @@ export default function activate(ctx: ExtensionContext) {
|
|
|
151
267
|
return;
|
|
152
268
|
}
|
|
153
269
|
|
|
270
|
+
// ImageMagick is optional; only used to shrink inline glyphs (config.inlineScale).
|
|
271
|
+
for (const bin of ["magick", "convert"]) {
|
|
272
|
+
try {
|
|
273
|
+
execSync(`${bin} --version`, { stdio: "ignore", timeout: 3000 });
|
|
274
|
+
magickBin = bin;
|
|
275
|
+
break;
|
|
276
|
+
} catch { /* not installed */ }
|
|
277
|
+
}
|
|
278
|
+
|
|
154
279
|
ctx.define("latex:render-equation", (equation: string): Buffer | null => renderEquationCached(equation));
|
|
155
280
|
|
|
156
281
|
if (ctx.shell) {
|
|
@@ -172,14 +297,34 @@ export default function activate(ctx: ExtensionContext) {
|
|
|
172
297
|
ctx.call("render:image", png);
|
|
173
298
|
});
|
|
174
299
|
} else {
|
|
300
|
+
// Cache the id per equation so each image is registered (and transmitted) once.
|
|
301
|
+
const inlineIds = new Map<string, number>();
|
|
302
|
+
const registerInline = (eq: string): number | null => {
|
|
303
|
+
const cached = inlineIds.get(eq);
|
|
304
|
+
if (cached !== undefined) return cached;
|
|
305
|
+
const png = renderInlineCached(eq);
|
|
306
|
+
if (!png) return null;
|
|
307
|
+
const id = ctx.call("ashi:inline-image:register", png) as number | null;
|
|
308
|
+
if (typeof id === "number") inlineIds.set(eq, id);
|
|
309
|
+
return typeof id === "number" ? id : null;
|
|
310
|
+
};
|
|
311
|
+
|
|
175
312
|
(bus.onPipe as unknown as (e: string, fn: (p: ContentPipe) => ContentPipe) => void)(
|
|
176
313
|
"render:assistant-content",
|
|
177
314
|
(payload) => {
|
|
178
315
|
// Can't show images reliably → leave $$…$$ as text.
|
|
179
316
|
if (!payload.images) return payload;
|
|
317
|
+
const canInline = ctx.list().includes("ashi:inline-image:register");
|
|
180
318
|
return {
|
|
181
319
|
...payload,
|
|
182
|
-
blocks: payload.blocks.flatMap((b) =>
|
|
320
|
+
blocks: payload.blocks.flatMap((b) => {
|
|
321
|
+
if (b.type !== "text") return [b];
|
|
322
|
+
return splitEquations(b.text).map((blk) =>
|
|
323
|
+
blk.type === "text" && canInline
|
|
324
|
+
? { type: "text" as const, text: replaceInline(blk.text, registerInline) }
|
|
325
|
+
: blk,
|
|
326
|
+
);
|
|
327
|
+
}),
|
|
183
328
|
};
|
|
184
329
|
},
|
|
185
330
|
);
|
package/package.json
CHANGED
package/src/agent/agent-loop.ts
CHANGED
|
@@ -462,6 +462,17 @@ export class AgentLoop implements AgentBackend {
|
|
|
462
462
|
}
|
|
463
463
|
|
|
464
464
|
|
|
465
|
+
/** Resume-stable conversation id from the frontend (e.g. ashi); undefined
|
|
466
|
+
* when the frontend tracks no session. */
|
|
467
|
+
private currentSessionId(): string | undefined {
|
|
468
|
+
try {
|
|
469
|
+
const id = this.handlers.call("session:current-id");
|
|
470
|
+
return typeof id === "string" && id ? id : undefined;
|
|
471
|
+
} catch {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
465
476
|
private resolveEndpoint(m: Model): ModelEndpoint | undefined {
|
|
466
477
|
try {
|
|
467
478
|
return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id }) as ModelEndpoint | undefined;
|
|
@@ -1439,7 +1450,12 @@ export class AgentLoop implements AgentBackend {
|
|
|
1439
1450
|
};
|
|
1440
1451
|
this.bus.emit("llm:request", requestParams);
|
|
1441
1452
|
|
|
1442
|
-
const
|
|
1453
|
+
const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
|
|
1454
|
+
const stream = await this.llmClient.stream({
|
|
1455
|
+
...requestParams,
|
|
1456
|
+
signal,
|
|
1457
|
+
...(headers && Object.keys(headers).length ? { headers } : {}),
|
|
1458
|
+
});
|
|
1443
1459
|
|
|
1444
1460
|
try {
|
|
1445
1461
|
for await (const chunk of stream) {
|
package/src/agent/events.ts
CHANGED
|
@@ -22,6 +22,7 @@ declare module "../core/event-bus.js" {
|
|
|
22
22
|
id: string;
|
|
23
23
|
reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
|
|
24
24
|
cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
25
|
+
requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
"agent:models-changed": Record<string, never>;
|
package/src/agent/host-types.ts
CHANGED
|
@@ -93,6 +93,7 @@ export interface ModelEndpoint {
|
|
|
93
93
|
baseURL?: string;
|
|
94
94
|
buildReasoningParams?: (level: string) => Record<string, unknown>;
|
|
95
95
|
extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
96
|
+
buildRequestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
// ── Agent-host extension surface ─────────────────────────────────
|
|
@@ -111,6 +112,7 @@ export interface AgentSurface {
|
|
|
111
112
|
configure: (id: string, opts: {
|
|
112
113
|
reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
|
|
113
114
|
cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
115
|
+
requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
|
|
114
116
|
}) => void;
|
|
115
117
|
};
|
|
116
118
|
|
package/src/agent/index.ts
CHANGED
|
@@ -128,6 +128,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
|
|
|
128
128
|
const providerHooks = new Map<string, {
|
|
129
129
|
reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
|
|
130
130
|
cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
131
|
+
requestHeaders?: (info: { sessionId?: string }) => Record<string, string>;
|
|
131
132
|
}>();
|
|
132
133
|
|
|
133
134
|
// Bakes model id so ModelEndpoint.buildReasoningParams keeps its (level) signature.
|
|
@@ -139,6 +140,9 @@ export default function agentBackend(ctx: ExtensionContext): void {
|
|
|
139
140
|
const bindCacheTokens = (shapeId: string) =>
|
|
140
141
|
providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
|
|
141
142
|
|
|
143
|
+
const bindRequestHeaders = (shapeId: string) =>
|
|
144
|
+
providerHooks.get(shapeId)?.requestHeaders;
|
|
145
|
+
|
|
142
146
|
const agentSurface: AgentSurface = {
|
|
143
147
|
llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
|
|
144
148
|
providers: {
|
|
@@ -345,6 +349,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
|
|
|
345
349
|
baseURL: p.baseURL,
|
|
346
350
|
buildReasoningParams: bindReasoning(shapeId, modelId),
|
|
347
351
|
extractCachedTokens: bindCacheTokens(shapeId),
|
|
352
|
+
buildRequestHeaders: bindRequestHeaders(shapeId),
|
|
348
353
|
};
|
|
349
354
|
};
|
|
350
355
|
|
|
@@ -388,10 +393,11 @@ export default function agentBackend(ctx: ExtensionContext): void {
|
|
|
388
393
|
}
|
|
389
394
|
});
|
|
390
395
|
|
|
391
|
-
bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
|
|
396
|
+
bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
|
|
392
397
|
const prev = providerHooks.get(id) ?? {};
|
|
393
398
|
if (reasoningParams !== undefined) prev.reasoningParams = reasoningParams;
|
|
394
399
|
if (cacheTokens !== undefined) prev.cacheTokens = cacheTokens;
|
|
400
|
+
if (requestHeaders !== undefined) prev.requestHeaders = requestHeaders;
|
|
395
401
|
providerHooks.set(id, prev);
|
|
396
402
|
});
|
|
397
403
|
|
package/src/agent/llm-client.ts
CHANGED
|
@@ -68,7 +68,7 @@ export class LlmClient {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
stream(opts: StreamOpts) {
|
|
71
|
-
const { signal, messages, tools, model, max_tokens, ...rest } = opts;
|
|
71
|
+
const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
|
|
72
72
|
const body = {
|
|
73
73
|
...rest,
|
|
74
74
|
model: model ?? this.model,
|
|
@@ -78,7 +78,7 @@ export class LlmClient {
|
|
|
78
78
|
stream: true as const,
|
|
79
79
|
stream_options: { include_usage: true },
|
|
80
80
|
};
|
|
81
|
-
return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal });
|
|
81
|
+
return this.client.chat.completions.create(body as ChatCompletionCreateParamsStreaming, { signal, headers });
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
async complete(opts: CompleteOpts): Promise<string> {
|
|
@@ -102,6 +102,8 @@ export type StreamOpts = {
|
|
|
102
102
|
model?: string;
|
|
103
103
|
max_tokens?: number;
|
|
104
104
|
signal?: AbortSignal;
|
|
105
|
+
/** Per-request transport headers, forwarded to the SDK (not request body). */
|
|
106
|
+
headers?: Record<string, string>;
|
|
105
107
|
} & Record<string, unknown>;
|
|
106
108
|
|
|
107
109
|
export type CompleteOpts = {
|
|
@@ -42,7 +42,16 @@ function toModalities(input?: string[]): ("text" | "image")[] | undefined {
|
|
|
42
42
|
|
|
43
43
|
export default function activate(ctx: AgentContext): void {
|
|
44
44
|
const apiKey = resolveApiKey("openrouter").key;
|
|
45
|
-
ctx.agent.providers.configure("openrouter", {
|
|
45
|
+
ctx.agent.providers.configure("openrouter", {
|
|
46
|
+
reasoningParams: buildReasoningParams,
|
|
47
|
+
// x-session-id pins sticky provider routing across turns so prompt caches
|
|
48
|
+
// stay warm even when compaction rewrites the opening messages.
|
|
49
|
+
requestHeaders: ({ sessionId }) => {
|
|
50
|
+
const headers: Record<string, string> = {};
|
|
51
|
+
if (sessionId) headers["x-session-id"] = sessionId;
|
|
52
|
+
return headers;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
46
55
|
ctx.agent.providers.register({
|
|
47
56
|
id: "openrouter",
|
|
48
57
|
apiKey: apiKey ?? undefined,
|
|
@@ -47,8 +47,16 @@ export const bashStrategy: ShellStrategy = {
|
|
|
47
47
|
' [[ $__agent_sh_preexec_ran == 1 ]] && return',
|
|
48
48
|
' [[ -n $COMP_LINE ]] && return',
|
|
49
49
|
" __agent_sh_preexec_ran=1",
|
|
50
|
-
" local this_cmd",
|
|
51
|
-
`
|
|
50
|
+
" local this_cmd hist_cmd",
|
|
51
|
+
` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
|
|
52
|
+
" # history 1 carries the full typed line but goes stale when the user's",
|
|
53
|
+
" # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
|
|
54
|
+
" # matches the command bash is about to run; else use $BASH_COMMAND.",
|
|
55
|
+
' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
|
|
56
|
+
" this_cmd=$hist_cmd",
|
|
57
|
+
" else",
|
|
58
|
+
" this_cmd=$BASH_COMMAND",
|
|
59
|
+
" fi",
|
|
52
60
|
` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
|
|
53
61
|
"}",
|
|
54
62
|
"trap '__agent_sh_emit_preexec' DEBUG",
|