@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.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/CHANGELOG.md +72 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +60 -12
- package/dist/types/edit/file-snapshot-store.d.ts +9 -6
- package/dist/types/edit/hashline/diff.d.ts +4 -5
- package/dist/types/edit/streaming.d.ts +2 -1
- package/dist/types/eval/py/index.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
- package/dist/types/lib/xai-http.d.ts +40 -0
- package/dist/types/mcp/transports/http.d.ts +9 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +4 -1
- package/dist/types/tools/fetch.d.ts +16 -0
- package/dist/types/tools/image-gen.d.ts +6 -2
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/match-line-format.d.ts +2 -2
- package/dist/types/tools/plan-mode-guard.d.ts +5 -6
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/tts.d.ts +18 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +2 -0
- package/package.json +8 -8
- package/src/cli/args.ts +2 -0
- package/src/cli/auth-broker-cli.ts +2 -1
- package/src/cli/auth-gateway-cli.ts +210 -9
- package/src/commands/auth-gateway.ts +7 -1
- package/src/config/model-registry.ts +41 -9
- package/src/config/settings-schema.ts +55 -13
- package/src/edit/file-snapshot-store.ts +9 -6
- package/src/edit/hashline/diff.ts +26 -13
- package/src/edit/hashline/execute.ts +13 -9
- package/src/edit/renderer.ts +9 -9
- package/src/edit/streaming.ts +4 -6
- package/src/eval/py/index.ts +1 -1
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/shared-events.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +2 -0
- package/src/internal-urls/vault-protocol.ts +936 -0
- package/src/lib/xai-http.ts +124 -0
- package/src/main.ts +1 -2
- package/src/mcp/transports/http.ts +29 -2
- package/src/modes/components/tool-execution.ts +6 -4
- package/src/modes/controllers/event-controller.ts +10 -3
- package/src/modes/controllers/selector-controller.ts +7 -2
- package/src/modes/interactive-mode.ts +11 -3
- package/src/modes/utils/ui-helpers.ts +2 -1
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +3 -3
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +41 -10
- package/src/session/agent-session.ts +112 -14
- package/src/system-prompt.ts +2 -0
- package/src/tools/ast-edit.ts +10 -7
- package/src/tools/ast-grep.ts +12 -11
- package/src/tools/eval.ts +28 -3
- package/src/tools/fetch.ts +52 -24
- package/src/tools/image-gen.ts +205 -7
- package/src/tools/index.ts +1 -0
- package/src/tools/match-line-format.ts +2 -2
- package/src/tools/path-utils.ts +2 -0
- package/src/tools/plan-mode-guard.ts +20 -7
- package/src/tools/read.ts +70 -55
- package/src/tools/render-utils.ts +15 -0
- package/src/tools/search.ts +14 -14
- package/src/tools/tts.ts +133 -0
- package/src/tools/write.ts +61 -6
- package/src/utils/file-mentions.ts +11 -5
- package/src/web/search/providers/codex.ts +2 -1
|
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
|
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import { scheduler } from "node:timers/promises";
|
|
20
20
|
import { isPromise } from "node:util/types";
|
|
21
|
+
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
21
22
|
import {
|
|
22
23
|
type AfterToolCallContext,
|
|
23
24
|
type AfterToolCallResult,
|
|
@@ -104,6 +105,8 @@ import { onAppendOnlyModeChanged } from "../config/settings";
|
|
|
104
105
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
105
106
|
import { loadCapability } from "../discovery";
|
|
106
107
|
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
108
|
+
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
|
|
109
|
+
import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
|
|
107
110
|
import {
|
|
108
111
|
disposeKernelSessionsByOwner,
|
|
109
112
|
executePython as executePythonCommand,
|
|
@@ -209,7 +212,11 @@ import { YieldQueue } from "./yield-queue";
|
|
|
209
212
|
/** Session-specific events that extend the core AgentEvent */
|
|
210
213
|
export type AgentSessionEvent =
|
|
211
214
|
| AgentEvent
|
|
212
|
-
| {
|
|
215
|
+
| {
|
|
216
|
+
type: "auto_compaction_start";
|
|
217
|
+
reason: "threshold" | "overflow" | "idle" | "incomplete";
|
|
218
|
+
action: "context-full" | "handoff";
|
|
219
|
+
}
|
|
213
220
|
| {
|
|
214
221
|
type: "auto_compaction_end";
|
|
215
222
|
action: "context-full" | "handoff";
|
|
@@ -738,6 +745,7 @@ export class AgentSession {
|
|
|
738
745
|
readonly sessionManager: SessionManager;
|
|
739
746
|
readonly settings: Settings;
|
|
740
747
|
readonly yieldQueue: YieldQueue;
|
|
748
|
+
fileSnapshotStore?: InMemorySnapshotStore;
|
|
741
749
|
|
|
742
750
|
#powerAssertion: MacOSPowerAssertion | undefined;
|
|
743
751
|
|
|
@@ -3631,9 +3639,17 @@ export class AgentSession {
|
|
|
3631
3639
|
const sessionOnResponse = this.#onResponse;
|
|
3632
3640
|
const sessionMetadata = this.agent.metadataForProvider(provider);
|
|
3633
3641
|
const sessionOnSseEvent = this.#onSseEvent;
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
const
|
|
3642
|
+
const openrouterRoutingPreset =
|
|
3643
|
+
provider === "openrouter" ? this.settings.get("providers.openrouterVariant") : "default";
|
|
3644
|
+
const openrouterVariant =
|
|
3645
|
+
openrouterRoutingPreset !== "default" && options.openrouterVariant === undefined
|
|
3646
|
+
? openrouterRoutingPreset
|
|
3647
|
+
: undefined;
|
|
3648
|
+
if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent && !openrouterVariant)
|
|
3649
|
+
return options;
|
|
3650
|
+
|
|
3651
|
+
const preparedOptions: SimpleStreamOptions =
|
|
3652
|
+
openrouterVariant === undefined ? { ...options } : { ...options, openrouterVariant };
|
|
3637
3653
|
|
|
3638
3654
|
// Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so
|
|
3639
3655
|
// they share the same session bucket as Agent.prompt-routed requests on Anthropic
|
|
@@ -3758,6 +3774,10 @@ export class AgentSession {
|
|
|
3758
3774
|
this.#planReferencePath = path;
|
|
3759
3775
|
}
|
|
3760
3776
|
|
|
3777
|
+
getPlanReferencePath(): string {
|
|
3778
|
+
return this.#planReferencePath;
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3761
3781
|
get clientBridge(): ClientBridge | undefined {
|
|
3762
3782
|
return this.#clientBridge;
|
|
3763
3783
|
}
|
|
@@ -4144,6 +4164,7 @@ export class AgentSession {
|
|
|
4144
4164
|
const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
|
|
4145
4165
|
autoResizeImages: this.settings.get("images.autoResize"),
|
|
4146
4166
|
useHashLines: resolveFileDisplayMode(this).hashLines,
|
|
4167
|
+
snapshotStore: getFileSnapshotStore(this),
|
|
4147
4168
|
});
|
|
4148
4169
|
messages.push(...fileMentionMessages);
|
|
4149
4170
|
}
|
|
@@ -5575,6 +5596,11 @@ export class AgentSession {
|
|
|
5575
5596
|
initiatorOverride: "agent",
|
|
5576
5597
|
metadata: this.agent.metadataForProvider(model.provider),
|
|
5577
5598
|
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
|
5599
|
+
// Honor the user's /model thinking selection on the handoff
|
|
5600
|
+
// path. Clamped per-model inside generateHandoff via
|
|
5601
|
+
// resolveCompactionEffort so unsupported-effort models don't
|
|
5602
|
+
// trip requireSupportedEffort.
|
|
5603
|
+
thinkingLevel: this.thinkingLevel,
|
|
5578
5604
|
},
|
|
5579
5605
|
handoffSignal,
|
|
5580
5606
|
);
|
|
@@ -5645,10 +5671,14 @@ export class AgentSession {
|
|
|
5645
5671
|
* Check if context maintenance or promotion is needed and run it.
|
|
5646
5672
|
* Called after agent_end and before prompt submission.
|
|
5647
5673
|
*
|
|
5648
|
-
*
|
|
5649
|
-
* 1.
|
|
5650
|
-
* 2.
|
|
5651
|
-
* 3.
|
|
5674
|
+
* Four cases (in order):
|
|
5675
|
+
* 1. Input overflow + promotion: promote to larger model, retry without maintenance.
|
|
5676
|
+
* 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model.
|
|
5677
|
+
* 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the
|
|
5678
|
+
* model burned its output budget without producing an actionable deliverable
|
|
5679
|
+
* (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise
|
|
5680
|
+
* run compaction/handoff and retry.
|
|
5681
|
+
* 4. Threshold: context over threshold, run context maintenance (no auto-retry).
|
|
5652
5682
|
*
|
|
5653
5683
|
* @param assistantMessage The assistant message to check
|
|
5654
5684
|
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
|
|
@@ -5707,10 +5737,49 @@ export class AgentSession {
|
|
|
5707
5737
|
}
|
|
5708
5738
|
return false;
|
|
5709
5739
|
}
|
|
5740
|
+
|
|
5741
|
+
// Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
|
|
5742
|
+
// (and Codex) maps to stopReason === "length". The model burned its
|
|
5743
|
+
// `max_output_tokens` budget on reasoning/text and emitted no actionable
|
|
5744
|
+
// deliverable. Same recovery class as overflow: promotion if available,
|
|
5745
|
+
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
|
|
5746
|
+
// allow the handoff strategy to actually run.
|
|
5747
|
+
if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
|
|
5748
|
+
const messages = this.agent.state.messages;
|
|
5749
|
+
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
5750
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
5751
|
+
}
|
|
5752
|
+
|
|
5753
|
+
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
5754
|
+
if (promoted) {
|
|
5755
|
+
logger.debug("Context promotion triggered by response.incomplete (length stop)", {
|
|
5756
|
+
from: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
5757
|
+
});
|
|
5758
|
+
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
5759
|
+
return false;
|
|
5760
|
+
}
|
|
5761
|
+
|
|
5762
|
+
const incompleteCompactionSettings = this.settings.getGroup("compaction");
|
|
5763
|
+
if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
|
|
5764
|
+
logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
|
|
5765
|
+
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
5766
|
+
strategy: incompleteCompactionSettings.strategy,
|
|
5767
|
+
});
|
|
5768
|
+
await this.#runAutoCompaction("incomplete", true, false, allowDefer);
|
|
5769
|
+
} else {
|
|
5770
|
+
// Neither promotion nor compaction is available — surface the dead-end so
|
|
5771
|
+
// the user understands why the turn yielded with nothing.
|
|
5772
|
+
logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", {
|
|
5773
|
+
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
5774
|
+
});
|
|
5775
|
+
}
|
|
5776
|
+
return false;
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5710
5779
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
5711
5780
|
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
|
|
5712
5781
|
|
|
5713
|
-
// Case
|
|
5782
|
+
// Case 4: Threshold - turn succeeded but context is getting large
|
|
5714
5783
|
// Skip if this was an error (non-overflow errors don't have usage data)
|
|
5715
5784
|
if (assistantMessage.stopReason === "error") return false;
|
|
5716
5785
|
const pruneResult = await this.#pruneToolOutputs();
|
|
@@ -6345,6 +6414,11 @@ export class AgentSession {
|
|
|
6345
6414
|
metadata: this.agent.metadataForProvider(candidate.provider),
|
|
6346
6415
|
convertToLlm,
|
|
6347
6416
|
telemetry,
|
|
6417
|
+
// Honor the user's /model thinking selection (incl. `off`) on
|
|
6418
|
+
// the manual `/compact` path. Clamped per-model inside compact()
|
|
6419
|
+
// via resolveCompactionEffort so unsupported-effort models
|
|
6420
|
+
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
|
|
6421
|
+
thinkingLevel: this.thinkingLevel,
|
|
6348
6422
|
});
|
|
6349
6423
|
} catch (error) {
|
|
6350
6424
|
if (!this.#isCompactionAuthFailure(error)) {
|
|
@@ -6428,7 +6502,7 @@ export class AgentSession {
|
|
|
6428
6502
|
* @returns true when a deferred handoff was scheduled. Inline runs always return false.
|
|
6429
6503
|
*/
|
|
6430
6504
|
async #runAutoCompaction(
|
|
6431
|
-
reason: "overflow" | "threshold" | "idle",
|
|
6505
|
+
reason: "overflow" | "threshold" | "idle" | "incomplete",
|
|
6432
6506
|
willRetry: boolean,
|
|
6433
6507
|
deferred = false,
|
|
6434
6508
|
allowDefer = true,
|
|
@@ -6437,10 +6511,14 @@ export class AgentSession {
|
|
|
6437
6511
|
if (compactionSettings.strategy === "off") return false;
|
|
6438
6512
|
if (reason !== "idle" && !compactionSettings.enabled) return false;
|
|
6439
6513
|
const generation = this.#promptGeneration;
|
|
6514
|
+
// "overflow" and "incomplete" force inline execution because they are recovery
|
|
6515
|
+
// paths the caller wants resolved before scheduling the next turn. "idle" is
|
|
6516
|
+
// triggered by the idle loop and does its own scheduling.
|
|
6440
6517
|
if (
|
|
6441
6518
|
!deferred &&
|
|
6442
6519
|
allowDefer &&
|
|
6443
6520
|
reason !== "overflow" &&
|
|
6521
|
+
reason !== "incomplete" &&
|
|
6444
6522
|
reason !== "idle" &&
|
|
6445
6523
|
compactionSettings.strategy === "handoff"
|
|
6446
6524
|
) {
|
|
@@ -6455,6 +6533,9 @@ export class AgentSession {
|
|
|
6455
6533
|
return true;
|
|
6456
6534
|
}
|
|
6457
6535
|
|
|
6536
|
+
// "overflow" forces context-full because the input itself is broken — a handoff
|
|
6537
|
+
// LLM call would hit the same overflow. "incomplete" is an output-side problem,
|
|
6538
|
+
// so a handoff request on the existing context is still viable.
|
|
6458
6539
|
let action: "context-full" | "handoff" =
|
|
6459
6540
|
compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
|
|
6460
6541
|
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
@@ -6617,6 +6698,11 @@ export class AgentSession {
|
|
|
6617
6698
|
initiatorOverride: "agent",
|
|
6618
6699
|
convertToLlm,
|
|
6619
6700
|
telemetry,
|
|
6701
|
+
// Honor the user's /model thinking selection on the
|
|
6702
|
+
// auto-compaction path — the most-fired compaction
|
|
6703
|
+
// site. Clamped per-model inside compact() via
|
|
6704
|
+
// resolveCompactionEffort.
|
|
6705
|
+
thinkingLevel: this.thinkingLevel,
|
|
6620
6706
|
});
|
|
6621
6707
|
break;
|
|
6622
6708
|
} catch (error) {
|
|
@@ -6750,8 +6836,18 @@ export class AgentSession {
|
|
|
6750
6836
|
if (willRetry) {
|
|
6751
6837
|
const messages = this.agent.state.messages;
|
|
6752
6838
|
const lastMsg = messages[messages.length - 1];
|
|
6753
|
-
if (lastMsg?.role === "assistant"
|
|
6754
|
-
|
|
6839
|
+
if (lastMsg?.role === "assistant") {
|
|
6840
|
+
const lastAssistant = lastMsg as AssistantMessage;
|
|
6841
|
+
// Drop the prior turn before retry when it carries no actionable deliverable:
|
|
6842
|
+
// - "error": failure was kept in history but must not re-enter the next turn's prompt.
|
|
6843
|
+
// - reason === "incomplete" && stopReason === "length": truncated output (typically
|
|
6844
|
+
// reasoning-only) — re-running it produces the same dead-end.
|
|
6845
|
+
const shouldDrop =
|
|
6846
|
+
lastAssistant.stopReason === "error" ||
|
|
6847
|
+
(reason === "incomplete" && lastAssistant.stopReason === "length");
|
|
6848
|
+
if (shouldDrop) {
|
|
6849
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
6850
|
+
}
|
|
6755
6851
|
}
|
|
6756
6852
|
|
|
6757
6853
|
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
@@ -6785,7 +6881,9 @@ export class AgentSession {
|
|
|
6785
6881
|
errorMessage:
|
|
6786
6882
|
reason === "overflow"
|
|
6787
6883
|
? `Context overflow recovery failed: ${errorMessage}`
|
|
6788
|
-
:
|
|
6884
|
+
: reason === "incomplete"
|
|
6885
|
+
? `Incomplete response recovery failed: ${errorMessage}`
|
|
6886
|
+
: `Auto-compaction failed: ${errorMessage}`,
|
|
6789
6887
|
});
|
|
6790
6888
|
} finally {
|
|
6791
6889
|
if (this.#autoCompactionAbortController === autoCompactionAbortController) {
|
|
@@ -7494,7 +7592,7 @@ export class AgentSession {
|
|
|
7494
7592
|
});
|
|
7495
7593
|
const result = await executePythonCommand(code, {
|
|
7496
7594
|
cwd,
|
|
7497
|
-
sessionId,
|
|
7595
|
+
sessionId: namespacePythonSessionId(sessionId),
|
|
7498
7596
|
kernelOwnerId: this.#evalKernelOwnerId,
|
|
7499
7597
|
kernelMode: this.settings.get("python.kernelMode"),
|
|
7500
7598
|
onChunk,
|
package/src/system-prompt.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { systemPromptCapability } from "./capability/system-prompt";
|
|
|
11
11
|
import type { SkillsSettings } from "./config/settings";
|
|
12
12
|
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
|
|
13
13
|
import { loadSkills, type Skill } from "./extensibility/skills";
|
|
14
|
+
import { hasObsidian } from "./internal-urls/vault-protocol";
|
|
14
15
|
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
|
|
15
16
|
import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
|
|
16
17
|
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
|
@@ -569,6 +570,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
569
570
|
mcpDiscoveryServerSummaries,
|
|
570
571
|
eagerTasks,
|
|
571
572
|
secretsEnabled,
|
|
573
|
+
hasObsidian: hasObsidian(),
|
|
572
574
|
};
|
|
573
575
|
const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
|
|
574
576
|
const systemPrompt = [rendered];
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { formatHashlineHeader } from "@oh-my-pi/hashline";
|
|
3
3
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
4
4
|
import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-my-pi/pi-natives";
|
|
5
5
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
6
6
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import * as z from "zod/v4";
|
|
9
|
+
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
|
|
10
|
+
import { normalizeToLF } from "../edit/normalize";
|
|
9
11
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
10
12
|
import type { Theme } from "../modes/theme/theme";
|
|
11
13
|
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
|
|
@@ -281,14 +283,15 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
|
284
|
-
const hashContexts = new Map<string, {
|
|
286
|
+
const hashContexts = new Map<string, { tag: string }>();
|
|
285
287
|
if (useHashLines) {
|
|
288
|
+
const snapshotStore = getFileSnapshotStore(this.session);
|
|
286
289
|
for (const relativePath of fileList) {
|
|
287
290
|
const absolutePath = path.resolve(this.session.cwd, relativePath);
|
|
288
291
|
try {
|
|
289
|
-
const fullText = await Bun.file(absolutePath).text();
|
|
290
|
-
const
|
|
291
|
-
hashContexts.set(relativePath, {
|
|
292
|
+
const fullText = normalizeToLF(await Bun.file(absolutePath).text());
|
|
293
|
+
const tag = snapshotStore.recordContiguous(absolutePath, 1, fullText.split("\n"), { fullText });
|
|
294
|
+
hashContexts.set(relativePath, { tag });
|
|
292
295
|
} catch {
|
|
293
296
|
// Best-effort: if a file disappears between ast-edit and rendering, emit plain line output.
|
|
294
297
|
}
|
|
@@ -326,7 +329,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
326
329
|
const rendered = renderChangesForFile(relativePath);
|
|
327
330
|
const count = fileReplacementCounts.get(relativePath) ?? 0;
|
|
328
331
|
const hashContext = hashContexts.get(relativePath);
|
|
329
|
-
const hashSuffix = hashContext ? `#${hashContext.
|
|
332
|
+
const hashSuffix = hashContext ? `#${hashContext.tag}` : "";
|
|
330
333
|
return {
|
|
331
334
|
headerSuffix: `${hashSuffix} (${formatCount("replacement", count)})`,
|
|
332
335
|
modelLines: rendered.model,
|
|
@@ -346,7 +349,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
346
349
|
}
|
|
347
350
|
const hashContext = hashContexts.get(relativePath);
|
|
348
351
|
if (hashContext) {
|
|
349
|
-
outputLines.push(formatHashlineHeader(relativePath, hashContext.
|
|
352
|
+
outputLines.push(formatHashlineHeader(relativePath, hashContext.tag));
|
|
350
353
|
}
|
|
351
354
|
outputLines.push(...rendered.model);
|
|
352
355
|
displayLines.push(...rendered.display);
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
1
3
|
import * as path from "node:path";
|
|
2
|
-
import {
|
|
4
|
+
import { formatHashlineHeader } from "@oh-my-pi/hashline";
|
|
3
5
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
4
6
|
import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives";
|
|
5
7
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
@@ -219,14 +221,14 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
219
221
|
}
|
|
220
222
|
|
|
221
223
|
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
|
222
|
-
const hashContexts = new Map<string, { absolutePath: string;
|
|
224
|
+
const hashContexts = new Map<string, { absolutePath: string; tag?: string }>();
|
|
225
|
+
const snapshotStore = useHashLines ? getFileSnapshotStore(this.session) : undefined;
|
|
223
226
|
if (useHashLines) {
|
|
224
227
|
for (const relativePath of fileList) {
|
|
225
228
|
const absolutePath = path.resolve(this.session.cwd, relativePath);
|
|
226
229
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
hashContexts.set(relativePath, { absolutePath, fileHash });
|
|
230
|
+
await access(absolutePath, constants.R_OK);
|
|
231
|
+
hashContexts.set(relativePath, { absolutePath });
|
|
230
232
|
} catch {
|
|
231
233
|
// Best-effort: if a file disappears between ast-grep and rendering, emit plain line output.
|
|
232
234
|
}
|
|
@@ -268,9 +270,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
268
270
|
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
|
269
271
|
}
|
|
270
272
|
if (hashContext && cacheEntries.length > 0) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
});
|
|
273
|
+
const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
|
|
274
|
+
if (tag) hashContext.tag = tag;
|
|
274
275
|
}
|
|
275
276
|
return { model: modelOut, display: displayOut };
|
|
276
277
|
};
|
|
@@ -282,7 +283,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
282
283
|
return {
|
|
283
284
|
modelLines: rendered.model,
|
|
284
285
|
displayLines: rendered.display,
|
|
285
|
-
headerSuffix: hashContext ? `#${hashContext.
|
|
286
|
+
headerSuffix: hashContext?.tag ? `#${hashContext.tag}` : "",
|
|
286
287
|
skip: rendered.model.length === 0,
|
|
287
288
|
};
|
|
288
289
|
});
|
|
@@ -297,8 +298,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
297
298
|
displayLines.push("");
|
|
298
299
|
}
|
|
299
300
|
const hashContext = hashContexts.get(relativePath);
|
|
300
|
-
if (hashContext) {
|
|
301
|
-
outputLines.push(formatHashlineHeader(relativePath, hashContext.
|
|
301
|
+
if (hashContext?.tag) {
|
|
302
|
+
outputLines.push(formatHashlineHeader(relativePath, hashContext.tag));
|
|
302
303
|
}
|
|
303
304
|
outputLines.push(...rendered.model);
|
|
304
305
|
displayLines.push(...rendered.display);
|
package/src/tools/eval.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
|
|
|
14
14
|
import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
|
|
15
15
|
import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
|
|
16
16
|
import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
|
|
17
|
+
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
|
|
17
18
|
import { resolveEvalBackends, type ToolSession } from ".";
|
|
18
19
|
import { truncateForPrompt } from "./approval";
|
|
19
20
|
import {
|
|
@@ -403,6 +404,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
403
404
|
|
|
404
405
|
const cellStatusEvents: EvalStatusEvent[] = [];
|
|
405
406
|
const cellDisplayOutputs: EvalDisplayOutput[] = [];
|
|
407
|
+
const cellImageNotes: string[] = [];
|
|
406
408
|
let cellHasMarkdown = false;
|
|
407
409
|
for (const output of result.displayOutputs) {
|
|
408
410
|
if (output.type === "json") {
|
|
@@ -410,8 +412,26 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
410
412
|
cellDisplayOutputs.push(output);
|
|
411
413
|
}
|
|
412
414
|
if (output.type === "image") {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
+
const resized = await resizeImage({
|
|
416
|
+
type: "image",
|
|
417
|
+
data: output.data,
|
|
418
|
+
mimeType: output.mimeType,
|
|
419
|
+
});
|
|
420
|
+
const image: ImageContent = {
|
|
421
|
+
type: "image",
|
|
422
|
+
data: resized.data,
|
|
423
|
+
mimeType: resized.mimeType,
|
|
424
|
+
};
|
|
425
|
+
images.push(image);
|
|
426
|
+
cellDisplayOutputs.push({
|
|
427
|
+
type: "image",
|
|
428
|
+
data: image.data,
|
|
429
|
+
mimeType: image.mimeType,
|
|
430
|
+
});
|
|
431
|
+
const dimensionNote = formatDimensionNote(resized);
|
|
432
|
+
if (dimensionNote) {
|
|
433
|
+
cellImageNotes.push(`display image ${cellImageNotes.length + 1}: ${dimensionNote}`);
|
|
434
|
+
}
|
|
415
435
|
}
|
|
416
436
|
if (output.type === "status") {
|
|
417
437
|
statusEvents.push(output.event);
|
|
@@ -423,9 +443,14 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
423
443
|
}
|
|
424
444
|
|
|
425
445
|
const stdoutTrimmed = result.output.trim();
|
|
446
|
+
const imageText = cellImageNotes.join("\n");
|
|
426
447
|
const displayText = formatDisplayOutputsForText(cellDisplayOutputs);
|
|
448
|
+
const visibleDisplayText =
|
|
449
|
+
displayText && imageText ? `${displayText}\n\n${imageText}` : displayText || imageText;
|
|
427
450
|
const cellOutput =
|
|
428
|
-
stdoutTrimmed &&
|
|
451
|
+
stdoutTrimmed && visibleDisplayText
|
|
452
|
+
? `${stdoutTrimmed}\n\n${visibleDisplayText}`
|
|
453
|
+
: stdoutTrimmed || visibleDisplayText;
|
|
429
454
|
cellResult.output = cellOutput;
|
|
430
455
|
cellResult.exitCode = result.exitCode;
|
|
431
456
|
cellResult.durationMs = durationMs;
|
package/src/tools/fetch.ts
CHANGED
|
@@ -562,9 +562,22 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
|
|
|
562
562
|
}
|
|
563
563
|
|
|
564
564
|
/**
|
|
565
|
-
*
|
|
565
|
+
* Cap on any single remote reader-mode request (Parallel, Jina) so a stalled
|
|
566
|
+
* remote endpoint cannot consume the whole reader-mode budget and starve the
|
|
567
|
+
* local fallback renderers (trafilatura, lynx, native). See #1449.
|
|
566
568
|
*/
|
|
567
|
-
|
|
569
|
+
const REMOTE_READER_MAX_MS = 10_000;
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Render HTML to markdown using Parallel, jina, trafilatura, lynx, then the
|
|
573
|
+
* in-process native converter. The overall `timeout` budget bounds the call,
|
|
574
|
+
* but remote reader requests are additionally capped at `REMOTE_READER_MAX_MS`
|
|
575
|
+
* so that a hung remote endpoint cannot prevent local fallbacks from running.
|
|
576
|
+
* Only a real `userSignal` cancellation aborts the chain — remote per-attempt
|
|
577
|
+
* timeouts and the overall reader-mode timeout still allow later renderers
|
|
578
|
+
* (especially the purely-local native converter) to be tried.
|
|
579
|
+
*/
|
|
580
|
+
export async function renderHtmlToText(
|
|
568
581
|
url: string,
|
|
569
582
|
html: string,
|
|
570
583
|
timeout: number,
|
|
@@ -572,14 +585,15 @@ async function renderHtmlToText(
|
|
|
572
585
|
userSignal: AbortSignal | undefined,
|
|
573
586
|
storage: AgentStorage | null,
|
|
574
587
|
): Promise<{ content: string; ok: boolean; method: string }> {
|
|
575
|
-
const
|
|
588
|
+
const overallSignal = ptree.combineSignals(userSignal, timeout * 1000);
|
|
576
589
|
const execOptions = {
|
|
577
590
|
mode: "group" as const,
|
|
578
591
|
allowNonZero: true,
|
|
579
592
|
allowAbort: true,
|
|
580
593
|
stderr: "full" as const,
|
|
581
|
-
signal,
|
|
594
|
+
signal: overallSignal,
|
|
582
595
|
};
|
|
596
|
+
const remoteBudgetMs = Math.min(timeout * 1000, REMOTE_READER_MAX_MS);
|
|
583
597
|
|
|
584
598
|
// Try Parallel extract first when credentials are configured
|
|
585
599
|
if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
|
|
@@ -590,7 +604,7 @@ async function renderHtmlToText(
|
|
|
590
604
|
objective: "Extract the main content",
|
|
591
605
|
excerpts: true,
|
|
592
606
|
fullContent: false,
|
|
593
|
-
signal,
|
|
607
|
+
signal: ptree.combineSignals(userSignal, remoteBudgetMs),
|
|
594
608
|
},
|
|
595
609
|
storage,
|
|
596
610
|
);
|
|
@@ -602,17 +616,18 @@ async function renderHtmlToText(
|
|
|
602
616
|
}
|
|
603
617
|
}
|
|
604
618
|
} catch {
|
|
605
|
-
// Parallel extract failed
|
|
606
|
-
|
|
619
|
+
// Parallel extract failed or stalled; honour real cancellation only.
|
|
620
|
+
userSignal?.throwIfAborted();
|
|
607
621
|
}
|
|
608
622
|
}
|
|
609
623
|
|
|
610
|
-
// Try jina
|
|
624
|
+
// Try jina reader API with its own sub-budget so a stall cannot starve
|
|
625
|
+
// later fallbacks (#1449).
|
|
611
626
|
try {
|
|
612
627
|
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
613
628
|
const response = await fetch(jinaUrl, {
|
|
614
629
|
headers: { Accept: "text/markdown" },
|
|
615
|
-
signal,
|
|
630
|
+
signal: ptree.combineSignals(userSignal, remoteBudgetMs),
|
|
616
631
|
});
|
|
617
632
|
if (response.ok) {
|
|
618
633
|
const content = await response.text();
|
|
@@ -621,37 +636,50 @@ async function renderHtmlToText(
|
|
|
621
636
|
}
|
|
622
637
|
}
|
|
623
638
|
} catch {
|
|
624
|
-
// Jina failed
|
|
625
|
-
|
|
639
|
+
// Jina failed or stalled; honour real cancellation only.
|
|
640
|
+
userSignal?.throwIfAborted();
|
|
626
641
|
}
|
|
627
642
|
|
|
628
643
|
// Try trafilatura (auto-install via uv/pip)
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
644
|
+
try {
|
|
645
|
+
const trafilatura = await ensureTool("trafilatura", { signal: overallSignal, silent: true });
|
|
646
|
+
if (trafilatura) {
|
|
647
|
+
const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
|
|
648
|
+
if (result.ok && result.stdout.trim().length > 100) {
|
|
649
|
+
return { content: result.stdout, ok: true, method: "trafilatura" };
|
|
650
|
+
}
|
|
634
651
|
}
|
|
652
|
+
} catch {
|
|
653
|
+
// trafilatura unavailable or stalled; continue to next method.
|
|
654
|
+
userSignal?.throwIfAborted();
|
|
635
655
|
}
|
|
636
656
|
|
|
637
657
|
// Try lynx (can't auto-install, system package)
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
658
|
+
try {
|
|
659
|
+
const lynx = hasCommand("lynx");
|
|
660
|
+
if (lynx) {
|
|
661
|
+
const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
|
|
662
|
+
if (result.ok) {
|
|
663
|
+
return { content: result.stdout, ok: true, method: "lynx" };
|
|
664
|
+
}
|
|
643
665
|
}
|
|
666
|
+
} catch {
|
|
667
|
+
// lynx failed or stalled; continue to native converter.
|
|
668
|
+
userSignal?.throwIfAborted();
|
|
644
669
|
}
|
|
645
670
|
|
|
646
|
-
// Fall back to native converter (
|
|
671
|
+
// Fall back to native converter (purely local, no network/subprocess).
|
|
672
|
+
// Always attempted: even if remote renderers and subprocesses were aborted
|
|
673
|
+
// by the overall reader-mode timeout, this still works on already-loaded
|
|
674
|
+
// HTML (#1449).
|
|
647
675
|
try {
|
|
648
676
|
const content = await htmlToMarkdown(html, { cleanContent: true });
|
|
649
677
|
if (content.trim().length > 100 && !isLowQualityOutput(content)) {
|
|
650
678
|
return { content, ok: true, method: "native" };
|
|
651
679
|
}
|
|
652
680
|
} catch {
|
|
653
|
-
// Native converter failed
|
|
654
|
-
|
|
681
|
+
// Native converter failed; nothing else to try.
|
|
682
|
+
userSignal?.throwIfAborted();
|
|
655
683
|
}
|
|
656
684
|
return { content: "", ok: false, method: "none" };
|
|
657
685
|
}
|