@oh-my-pi/pi-coding-agent 17.1.6 → 17.1.7
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 +23 -0
- package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-5k4dq4g6.md} +23 -0
- package/dist/cli.js +3149 -3092
- package/dist/types/config/settings-schema.d.ts +16 -1
- package/dist/types/cursor.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +4 -0
- package/dist/types/extensibility/shared-events.d.ts +18 -1
- package/dist/types/modes/components/custom-editor.d.ts +5 -0
- package/dist/types/session/agent-session-types.d.ts +5 -5
- package/dist/types/session/agent-session.d.ts +26 -1
- package/dist/types/session/model-controls.d.ts +1 -1
- package/dist/types/session/session-tools.d.ts +44 -6
- package/dist/types/session/streaming-output.d.ts +7 -2
- package/dist/types/session/turn-recovery.d.ts +1 -1
- package/dist/types/tools/index.d.ts +8 -3
- package/dist/types/tools/output-meta.d.ts +5 -0
- package/dist/types/tools/read.d.ts +9 -1
- package/dist/types/tools/xdev.d.ts +53 -67
- package/dist/types/utils/cpuprofile.d.ts +51 -0
- package/dist/types/utils/inspect-image-mode.d.ts +29 -0
- package/dist/types/utils/profile-tree.d.ts +47 -0
- package/dist/types/utils/sample-profile.d.ts +67 -0
- package/package.json +12 -12
- package/src/config/settings-schema.ts +15 -1
- package/src/config/settings.ts +35 -0
- package/src/cursor.ts +4 -3
- package/src/discovery/builtin-rules/index.ts +2 -0
- package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
- package/src/extensibility/extensions/runner.ts +26 -0
- package/src/extensibility/extensions/wrapper.ts +74 -42
- package/src/extensibility/hooks/tool-wrapper.ts +11 -4
- package/src/extensibility/shared-events.ts +18 -1
- package/src/modes/components/custom-editor.ts +39 -16
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +8 -7
- package/src/modes/controllers/extension-ui-controller.ts +4 -4
- package/src/modes/controllers/selector-controller.ts +7 -2
- package/src/sdk.ts +47 -50
- package/src/session/agent-session-types.ts +5 -5
- package/src/session/agent-session.ts +123 -7
- package/src/session/model-controls.ts +5 -5
- package/src/session/session-listing.ts +66 -4
- package/src/session/session-tools.ts +158 -52
- package/src/session/streaming-output.ts +18 -6
- package/src/session/turn-recovery.ts +4 -4
- package/src/slash-commands/builtin-registry.ts +69 -0
- package/src/tools/bash.ts +16 -9
- package/src/tools/index.ts +36 -16
- package/src/tools/output-meta.ts +20 -0
- package/src/tools/read.ts +87 -13
- package/src/tools/write.ts +16 -7
- package/src/tools/xdev.ts +198 -210
- package/src/utils/cpuprofile.ts +235 -0
- package/src/utils/inspect-image-mode.ts +39 -0
- package/src/utils/profile-tree.ts +111 -0
- package/src/utils/sample-profile.ts +437 -0
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
type AgentTurnEndContext,
|
|
36
36
|
AppendOnlyContextManager,
|
|
37
37
|
type AsideMessage,
|
|
38
|
+
type BeforeToolCallContext,
|
|
39
|
+
type BeforeToolCallResult,
|
|
38
40
|
resolveTelemetry,
|
|
39
41
|
type StreamFn,
|
|
40
42
|
TERMINAL_TOOL_RESULT_ABORT_REASON,
|
|
@@ -70,6 +72,7 @@ import type {
|
|
|
70
72
|
ToolChoice,
|
|
71
73
|
ToolResultMessage,
|
|
72
74
|
UsageReport,
|
|
75
|
+
UserMessage,
|
|
73
76
|
} from "@oh-my-pi/pi-ai";
|
|
74
77
|
import { type Effort, streamSimple } from "@oh-my-pi/pi-ai";
|
|
75
78
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
@@ -133,6 +136,7 @@ import type { CompactOptions, ContextUsage } from "../extensibility/extensions/t
|
|
|
133
136
|
import type { HookCommandContext } from "../extensibility/hooks/types";
|
|
134
137
|
import type { Skill, SkillWarning } from "../extensibility/skills";
|
|
135
138
|
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
|
139
|
+
import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
|
|
136
140
|
import { GoalRuntime } from "../goals/runtime";
|
|
137
141
|
import type { GoalModeState } from "../goals/state";
|
|
138
142
|
import type { HindsightSessionState } from "../hindsight/state";
|
|
@@ -176,6 +180,7 @@ import {
|
|
|
176
180
|
toReasoningEffort,
|
|
177
181
|
} from "../thinking";
|
|
178
182
|
import { shutdownTinyTitleClient } from "../tiny/title-client";
|
|
183
|
+
import { resolveApproval } from "../tools/approval";
|
|
179
184
|
import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask";
|
|
180
185
|
import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
|
|
181
186
|
import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint";
|
|
@@ -196,6 +201,7 @@ import type { EditMode } from "../utils/edit-mode";
|
|
|
196
201
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
197
202
|
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
|
198
203
|
import { normalizeModelContextImages } from "../utils/image-loading";
|
|
204
|
+
import type { InspectImageMode } from "../utils/inspect-image-mode";
|
|
199
205
|
import { generateSessionTitle } from "../utils/title-generator";
|
|
200
206
|
import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
|
|
201
207
|
import type { VibeModeState } from "../vibe/state";
|
|
@@ -429,6 +435,8 @@ export class AgentSession {
|
|
|
429
435
|
#scheduledHiddenNextTurnGeneration: number | undefined = undefined;
|
|
430
436
|
#queuedMessageDrainScheduled = false;
|
|
431
437
|
#planModeState: PlanModeState | undefined;
|
|
438
|
+
/** Session-scoped `/vision` override; undefined = follow persisted `inspect_image.mode`. */
|
|
439
|
+
#inspectImageModeOverride: InspectImageMode | undefined;
|
|
432
440
|
#vibeModeState: VibeModeState | undefined;
|
|
433
441
|
#goalModeState: GoalModeState | undefined;
|
|
434
442
|
#goalRuntime: GoalRuntime;
|
|
@@ -1081,20 +1089,24 @@ export class AgentSession {
|
|
|
1081
1089
|
emitNotice: (level, message, source) => this.emitNotice(level, message, source),
|
|
1082
1090
|
notifyCommandMetadataChanged: () => this.#notifyCommandMetadataChanged(),
|
|
1083
1091
|
localProtocolOptions: () => this.#localProtocolOptions(),
|
|
1092
|
+
getInspectImageModeOverride: () => this.#inspectImageModeOverride,
|
|
1093
|
+
setInspectImageModeOverride: mode => {
|
|
1094
|
+
this.#inspectImageModeOverride = mode;
|
|
1095
|
+
},
|
|
1084
1096
|
};
|
|
1085
1097
|
this.#tools = new SessionTools(sessionToolsHost, {
|
|
1086
1098
|
autoApprove: config.autoApprove,
|
|
1087
1099
|
toolRegistry: config.toolRegistry,
|
|
1088
1100
|
createVibeTools: config.createVibeTools,
|
|
1089
1101
|
createComputerTool: config.createComputerTool,
|
|
1102
|
+
createInspectImageTool: config.createInspectImageTool,
|
|
1090
1103
|
builtInToolNames: config.builtInToolNames,
|
|
1091
1104
|
presentationPinnedToolNames: config.presentationPinnedToolNames,
|
|
1092
1105
|
ensureWriteRegistered: config.ensureWriteRegistered,
|
|
1093
1106
|
rebuildSystemPrompt: config.rebuildSystemPrompt,
|
|
1094
1107
|
getLocalCalendarDate: config.getLocalCalendarDate,
|
|
1095
1108
|
getMcpServerInstructions: config.getMcpServerInstructions,
|
|
1096
|
-
|
|
1097
|
-
initialMountedXdevToolNames: config.initialMountedXdevToolNames,
|
|
1109
|
+
xdev: config.xdev,
|
|
1098
1110
|
setActiveToolNames: config.setActiveToolNames,
|
|
1099
1111
|
baseSystemPrompt: this.agent.state.systemPrompt,
|
|
1100
1112
|
skills: config.skills,
|
|
@@ -1177,6 +1189,10 @@ export class AgentSession {
|
|
|
1177
1189
|
});
|
|
1178
1190
|
// Tool-result hook owns synchronous post-tool actions that must affect the current loop.
|
|
1179
1191
|
this.agent.afterToolCall = ctx => this.#afterToolCall(ctx);
|
|
1192
|
+
// Pre-scheduling tool_call wiring: extension handlers run at arg-prep
|
|
1193
|
+
// time so a block/revision lands before concurrency resolution,
|
|
1194
|
+
// tool_execution_start, and the wrapper's approval gate.
|
|
1195
|
+
this.agent.beforeToolCall = ctx => this.#beforeToolCall(ctx);
|
|
1180
1196
|
this.agent.providerSessionState = this.#providerSessionState;
|
|
1181
1197
|
this.#syncAgentSessionId();
|
|
1182
1198
|
this.#todo.syncFromBranch();
|
|
@@ -2961,6 +2977,50 @@ export class AgentSession {
|
|
|
2961
2977
|
}
|
|
2962
2978
|
return this.#ttsr.afterToolCall(ctx);
|
|
2963
2979
|
}
|
|
2980
|
+
/**
|
|
2981
|
+
* Emits the extension `tool_call` event for a loop-dispatched call at
|
|
2982
|
+
* arg-prep time — before concurrency scheduling, `tool_execution_start`,
|
|
2983
|
+
* and the wrapper's approval gate. A handler block becomes a blocked tool
|
|
2984
|
+
* result; a handler `input` revision becomes the arguments the loop
|
|
2985
|
+
* schedules, displays, persists, and executes, so approval resolves against
|
|
2986
|
+
* what actually runs. Marks the dispatch so `ExtensionToolWrapper` does not
|
|
2987
|
+
* emit a second event (nested xd:// device dispatches and direct non-loop
|
|
2988
|
+
* execution still emit there).
|
|
2989
|
+
*/
|
|
2990
|
+
async #beforeToolCall(ctx: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> {
|
|
2991
|
+
const runner = this.#extensionRunner;
|
|
2992
|
+
if (!runner?.hasHandlers("tool_call")) return undefined;
|
|
2993
|
+
const metadata = ctx.toolCall.providerMetadata;
|
|
2994
|
+
const computer = metadata?.type === "computer" ? metadata : undefined;
|
|
2995
|
+
// Parity with the wrapper's pre-emit short-circuit: an already-denied
|
|
2996
|
+
// call never reaches extensions. Deny is mode-independent (tool decision
|
|
2997
|
+
// or user policy), so resolving under the most permissive mode is exact;
|
|
2998
|
+
// the wrapper still enforces the mode-accurate gate before execution.
|
|
2999
|
+
const userPolicies = (this.settings.get("tools.approval") ?? {}) as Record<string, unknown>;
|
|
3000
|
+
const approvalArgs = computer ? { actions: computer.actions } : ctx.args;
|
|
3001
|
+
if (resolveApproval(ctx.tool, approvalArgs, "yolo", userPolicies).policy === "deny") {
|
|
3002
|
+
return undefined;
|
|
3003
|
+
}
|
|
3004
|
+
const eventArgs = computer
|
|
3005
|
+
? { actions: computer.actions, pendingSafetyChecks: computer.pendingSafetyChecks }
|
|
3006
|
+
: ctx.args;
|
|
3007
|
+
runner.markToolCallEmitted(ctx.toolCall.id, ctx.tool.name);
|
|
3008
|
+
const callResult = await runner.emitToolCall({
|
|
3009
|
+
type: "tool_call",
|
|
3010
|
+
toolName: ctx.tool.name,
|
|
3011
|
+
toolCallId: ctx.toolCall.id,
|
|
3012
|
+
input: normalizeToolEventInput(ctx.tool.name, resolveToolEventInput(ctx.tool, eventArgs)),
|
|
3013
|
+
});
|
|
3014
|
+
if (callResult?.block) {
|
|
3015
|
+
return { block: true, reason: callResult.reason || "Tool execution was blocked by an extension" };
|
|
3016
|
+
}
|
|
3017
|
+
// A computer call's event input is a synthetic {actions, pendingSafetyChecks}
|
|
3018
|
+
// view, not the execution params — a revision cannot map back onto them.
|
|
3019
|
+
if (callResult?.input !== undefined && !computer) {
|
|
3020
|
+
return { args: callResult.input };
|
|
3021
|
+
}
|
|
3022
|
+
return undefined;
|
|
3023
|
+
}
|
|
2964
3024
|
|
|
2965
3025
|
/** Find the last assistant message in agent state (including aborted ones) */
|
|
2966
3026
|
#findLastAssistantMessage(): AssistantMessage | undefined {
|
|
@@ -3950,6 +4010,34 @@ export class AgentSession {
|
|
|
3950
4010
|
return this.#tools.setComputerToolEnabled(enabled);
|
|
3951
4011
|
}
|
|
3952
4012
|
|
|
4013
|
+
/**
|
|
4014
|
+
* Session-scoped inspect_image mode (`/vision`). `auto` clears the override
|
|
4015
|
+
* and returns to the persisted `inspect_image.mode` setting; `on`/`off`
|
|
4016
|
+
* force the tool for this session only. See {@link SessionTools.setInspectImageMode}.
|
|
4017
|
+
*/
|
|
4018
|
+
setInspectImageMode(mode: InspectImageMode): Promise<boolean> {
|
|
4019
|
+
return this.#tools.setInspectImageMode(mode);
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
/** Effective inspect_image state for `/vision status`. */
|
|
4023
|
+
inspectImageState(): { mode: InspectImageMode; active: boolean; model: string | undefined } {
|
|
4024
|
+
return this.#tools.inspectImageState();
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
/** Session-scoped `/vision` override; undefined means "follow the persisted setting". */
|
|
4028
|
+
getInspectImageModeOverride(): InspectImageMode | undefined {
|
|
4029
|
+
return this.#inspectImageModeOverride;
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
/**
|
|
4033
|
+
* Reconciles the inspect_image tool set after the persisted
|
|
4034
|
+
* `inspect_image.mode` setting changed (e.g. via the settings selector), so
|
|
4035
|
+
* the new value takes effect immediately instead of on the next model switch.
|
|
4036
|
+
*/
|
|
4037
|
+
applyInspectImageModeChange(): Promise<boolean> {
|
|
4038
|
+
return this.#tools.reconcileInspectImageTool();
|
|
4039
|
+
}
|
|
4040
|
+
|
|
3953
4041
|
/** Cancels the local rollout-memory startup owned by this session. */
|
|
3954
4042
|
cancelLocalMemoryStartup(): void {
|
|
3955
4043
|
this.#memory.cancelLocalMemoryStartup();
|
|
@@ -6405,7 +6493,7 @@ export class AgentSession {
|
|
|
6405
6493
|
return true;
|
|
6406
6494
|
}
|
|
6407
6495
|
|
|
6408
|
-
#setModelWithProviderSessionReset(model: Model): void {
|
|
6496
|
+
async #setModelWithProviderSessionReset(model: Model): Promise<void> {
|
|
6409
6497
|
const currentModel = this.model;
|
|
6410
6498
|
if (currentModel) {
|
|
6411
6499
|
this.#closeProviderSessionsForModelSwitch(currentModel, model);
|
|
@@ -6417,6 +6505,16 @@ export class AgentSession {
|
|
|
6417
6505
|
|
|
6418
6506
|
// Re-evaluate append-only context mode — provider or setting may have changed
|
|
6419
6507
|
this.#syncAppendOnlyContext(model);
|
|
6508
|
+
|
|
6509
|
+
// inspect_image auto mode keys off model image capability. Reconcile
|
|
6510
|
+
// centrally here so retry-fallback model changes (turn-recovery.ts),
|
|
6511
|
+
// which bypass syncAfterModelChange, cannot leave the tool set stale —
|
|
6512
|
+
// callers await, so a scheduled retry never races the reconciled slate.
|
|
6513
|
+
try {
|
|
6514
|
+
await this.#tools.reconcileInspectImageAfterModelChange();
|
|
6515
|
+
} catch (error) {
|
|
6516
|
+
logger.warn("inspect_image reconcile after model change failed", { error: String(error) });
|
|
6517
|
+
}
|
|
6420
6518
|
}
|
|
6421
6519
|
|
|
6422
6520
|
#closeCodexProviderSessionsForHistoryRewrite(): void {
|
|
@@ -6981,7 +7079,7 @@ export class AgentSession {
|
|
|
6981
7079
|
currentModel.id !== match.id ||
|
|
6982
7080
|
currentModel.api !== match.api));
|
|
6983
7081
|
if (shouldResetProviderState) {
|
|
6984
|
-
this.#setModelWithProviderSessionReset(match);
|
|
7082
|
+
await this.#setModelWithProviderSessionReset(match);
|
|
6985
7083
|
} else {
|
|
6986
7084
|
this.agent.setModel(match);
|
|
6987
7085
|
}
|
|
@@ -7108,10 +7206,12 @@ export class AgentSession {
|
|
|
7108
7206
|
* @param entryId ID of the entry to branch from
|
|
7109
7207
|
* @returns Object with:
|
|
7110
7208
|
* - selectedText: The text of the selected user message (for editor pre-fill)
|
|
7209
|
+
* - selectedImages: Image attachments of the selected user message (for editor draft restore)
|
|
7111
7210
|
* - cancelled: True if a hook cancelled the branch
|
|
7112
7211
|
*/
|
|
7113
7212
|
async branch(entryId: string): Promise<{
|
|
7114
7213
|
selectedText: string;
|
|
7214
|
+
selectedImages: ImageContent[];
|
|
7115
7215
|
cancelled: boolean;
|
|
7116
7216
|
}> {
|
|
7117
7217
|
const previousSessionFile = this.sessionFile;
|
|
@@ -7122,6 +7222,7 @@ export class AgentSession {
|
|
|
7122
7222
|
}
|
|
7123
7223
|
|
|
7124
7224
|
const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
|
|
7225
|
+
const selectedImages = this.#extractUserMessageImages(selectedEntry.message.content);
|
|
7125
7226
|
|
|
7126
7227
|
let skipConversationRestore = false;
|
|
7127
7228
|
|
|
@@ -7133,7 +7234,7 @@ export class AgentSession {
|
|
|
7133
7234
|
})) as SessionBeforeBranchResult | undefined;
|
|
7134
7235
|
|
|
7135
7236
|
if (result?.cancel) {
|
|
7136
|
-
return { selectedText, cancelled: true };
|
|
7237
|
+
return { selectedText, selectedImages, cancelled: true };
|
|
7137
7238
|
}
|
|
7138
7239
|
skipConversationRestore = result?.skipConversationRestore ?? false;
|
|
7139
7240
|
}
|
|
@@ -7189,7 +7290,7 @@ export class AgentSession {
|
|
|
7189
7290
|
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
7190
7291
|
}
|
|
7191
7292
|
|
|
7192
|
-
return { selectedText, cancelled: false };
|
|
7293
|
+
return { selectedText, selectedImages, cancelled: false };
|
|
7193
7294
|
}
|
|
7194
7295
|
|
|
7195
7296
|
async branchFromBtw(
|
|
@@ -7304,7 +7405,7 @@ export class AgentSession {
|
|
|
7304
7405
|
* @param targetId The entry ID to navigate to
|
|
7305
7406
|
* @param options.summarize Whether user wants to summarize abandoned branch
|
|
7306
7407
|
* @param options.customInstructions Custom instructions for summarizer
|
|
7307
|
-
* @returns Result with editorText (if user message) and cancelled status
|
|
7408
|
+
* @returns Result with editorText/editorImages (if user message) and cancelled status
|
|
7308
7409
|
*/
|
|
7309
7410
|
async navigateTree(
|
|
7310
7411
|
targetId: string,
|
|
@@ -7334,6 +7435,8 @@ export class AgentSession {
|
|
|
7334
7435
|
} = {},
|
|
7335
7436
|
): Promise<{
|
|
7336
7437
|
editorText?: string;
|
|
7438
|
+
/** Image attachments of the target user message, parallel to the positional `[Image #N]` markers in {@link editorText}. */
|
|
7439
|
+
editorImages?: ImageContent[];
|
|
7337
7440
|
cancelled: boolean;
|
|
7338
7441
|
aborted?: boolean;
|
|
7339
7442
|
summaryEntry?: BranchSummaryEntry;
|
|
@@ -7506,6 +7609,7 @@ export class AgentSession {
|
|
|
7506
7609
|
// Determine the new leaf position based on target type
|
|
7507
7610
|
let newLeafId: string | null;
|
|
7508
7611
|
let editorText: string | undefined;
|
|
7612
|
+
let editorImages: ImageContent[] | undefined;
|
|
7509
7613
|
// Set when the second-pass `ask` re-answer branch below actually commits a
|
|
7510
7614
|
// new sibling answer — the trigger for resuming the agent afterwards so the
|
|
7511
7615
|
// model consumes it, mirroring a live `ask` completion (issue #6483).
|
|
@@ -7515,6 +7619,8 @@ export class AgentSession {
|
|
|
7515
7619
|
// User message: leaf = parent (null if root), text goes to editor
|
|
7516
7620
|
newLeafId = targetEntry.parentId;
|
|
7517
7621
|
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
|
7622
|
+
const targetImages = this.#extractUserMessageImages(targetEntry.message.content);
|
|
7623
|
+
if (targetImages.length > 0) editorImages = targetImages;
|
|
7518
7624
|
} else if (targetEntry.type === "custom_message" && targetEntry.customType !== SKILL_PROMPT_MESSAGE_TYPE) {
|
|
7519
7625
|
// Custom message: leaf = parent (null if root), text goes to editor
|
|
7520
7626
|
newLeafId = targetEntry.parentId;
|
|
@@ -7614,6 +7720,7 @@ export class AgentSession {
|
|
|
7614
7720
|
const rawContext = this.sessionManager.buildSessionContext();
|
|
7615
7721
|
return {
|
|
7616
7722
|
editorText,
|
|
7723
|
+
editorImages,
|
|
7617
7724
|
cancelled: false,
|
|
7618
7725
|
summaryEntry,
|
|
7619
7726
|
sessionContext: rawContext,
|
|
@@ -7622,6 +7729,7 @@ export class AgentSession {
|
|
|
7622
7729
|
}
|
|
7623
7730
|
return {
|
|
7624
7731
|
editorText,
|
|
7732
|
+
editorImages,
|
|
7625
7733
|
cancelled: false,
|
|
7626
7734
|
summaryEntry,
|
|
7627
7735
|
sessionContext: stateContext,
|
|
@@ -7740,6 +7848,14 @@ export class AgentSession {
|
|
|
7740
7848
|
return "";
|
|
7741
7849
|
}
|
|
7742
7850
|
|
|
7851
|
+
/** Image parts of a stored user message, in submission order — index N-1 backs the
|
|
7852
|
+
* `[Image #N]` marker in the message text, so restoring them alongside the text keeps
|
|
7853
|
+
* positional markers resolvable on resubmit. */
|
|
7854
|
+
#extractUserMessageImages(content: UserMessage["content"]): ImageContent[] {
|
|
7855
|
+
if (!Array.isArray(content)) return [];
|
|
7856
|
+
return content.filter((c): c is ImageContent => c.type === "image");
|
|
7857
|
+
}
|
|
7858
|
+
|
|
7743
7859
|
/**
|
|
7744
7860
|
* Get session statistics.
|
|
7745
7861
|
*/
|
|
@@ -52,7 +52,7 @@ export interface ModelControlsHost {
|
|
|
52
52
|
promptGeneration(): number;
|
|
53
53
|
resolveActiveEditMode(): EditMode;
|
|
54
54
|
syncAfterModelChange(previousEditMode: EditMode): Promise<void>;
|
|
55
|
-
setModelWithProviderSessionReset(model: Model): void
|
|
55
|
+
setModelWithProviderSessionReset(model: Model): Promise<void>;
|
|
56
56
|
clearActiveRetryFallback(): void;
|
|
57
57
|
clearInheritedProviderPromptCacheKey(): void;
|
|
58
58
|
magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean;
|
|
@@ -220,7 +220,7 @@ export class ModelControls {
|
|
|
220
220
|
|
|
221
221
|
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
|
|
222
222
|
this.#host.clearActiveRetryFallback();
|
|
223
|
-
this.#host.setModelWithProviderSessionReset(targetModel);
|
|
223
|
+
await this.#host.setModelWithProviderSessionReset(targetModel);
|
|
224
224
|
this.#host.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
|
|
225
225
|
if (options?.persist) {
|
|
226
226
|
this.#host.settings.setModelRole(
|
|
@@ -265,7 +265,7 @@ export class ModelControls {
|
|
|
265
265
|
|
|
266
266
|
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
|
|
267
267
|
this.#host.clearActiveRetryFallback();
|
|
268
|
-
this.#host.setModelWithProviderSessionReset(targetModel);
|
|
268
|
+
await this.#host.setModelWithProviderSessionReset(targetModel);
|
|
269
269
|
this.#host.sessionManager.appendModelChange(
|
|
270
270
|
`${targetModel.provider}/${targetModel.id}`,
|
|
271
271
|
options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
|
|
@@ -425,7 +425,7 @@ export class ModelControls {
|
|
|
425
425
|
// Apply model
|
|
426
426
|
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(next.model));
|
|
427
427
|
this.#host.clearActiveRetryFallback();
|
|
428
|
-
this.#host.setModelWithProviderSessionReset(next.model);
|
|
428
|
+
await this.#host.setModelWithProviderSessionReset(next.model);
|
|
429
429
|
this.#host.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
|
|
430
430
|
this.#host.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
|
|
431
431
|
|
|
@@ -456,7 +456,7 @@ export class ModelControls {
|
|
|
456
456
|
|
|
457
457
|
this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(nextModel));
|
|
458
458
|
this.#host.clearActiveRetryFallback();
|
|
459
|
-
this.#host.setModelWithProviderSessionReset(nextModel);
|
|
459
|
+
await this.#host.setModelWithProviderSessionReset(nextModel);
|
|
460
460
|
this.#host.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
|
|
461
461
|
this.#host.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
|
|
462
462
|
// Re-apply the current thinking level (or auto) for the newly selected model
|
|
@@ -2,8 +2,9 @@ import * as os from "node:os";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { getAgentDir as getDefaultAgentDir, logger, parseJsonlLenient, toError } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { LRUCache } from "lru-cache/raw";
|
|
5
6
|
import { computeDefaultSessionDir } from "./session-paths";
|
|
6
|
-
import { FileSessionStorage, type SessionStorage } from "./session-storage";
|
|
7
|
+
import { FileSessionStorage, type SessionStorage, type SessionStorageStat } from "./session-storage";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Coarse lifecycle status of a session, derived from its last persisted message.
|
|
@@ -65,6 +66,44 @@ const SESSION_LIST_SUFFIX_BYTES = 32_768;
|
|
|
65
66
|
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
66
67
|
const SESSION_LIST_MAX_WORKERS = 16;
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Memoizes {@link scanSessionFile} results keyed by stat identity so listing
|
|
71
|
+
* refreshes (resume picker opens, startup recent-sessions, cross-project
|
|
72
|
+
* scans) skip the open+read+parse for unchanged files. The `statSync` still
|
|
73
|
+
* runs on every scan — it IS the invalidation check: a hit requires both
|
|
74
|
+
* `mtimeMs` and `size` to match. This covers the two mutation paths:
|
|
75
|
+
* - streaming appends grow `size` (and bump `mtimeMs`);
|
|
76
|
+
* - `updateSessionTitle` rewrites the fixed-width title slot in place via
|
|
77
|
+
* `writeSync`, which leaves `size` unchanged but updates `mtimeMs`.
|
|
78
|
+
* Negative results (unparseable files) are cached too, as `undefined` info.
|
|
79
|
+
* Entries are small header objects, so a generous cap is cheap.
|
|
80
|
+
*/
|
|
81
|
+
const SESSION_SCAN_CACHE_MAX = 4096;
|
|
82
|
+
|
|
83
|
+
interface SessionScanCacheEntry {
|
|
84
|
+
mtimeMs: number;
|
|
85
|
+
size: number;
|
|
86
|
+
info: SessionInfo | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type SessionScanCache = LRUCache<string, SessionScanCacheEntry>;
|
|
90
|
+
|
|
91
|
+
/** All {@link FileSessionStorage} instances view the same real filesystem, so they share one cache. */
|
|
92
|
+
const fileSessionScanCache: SessionScanCache = new LRUCache({ max: SESSION_SCAN_CACHE_MAX });
|
|
93
|
+
/** Other storages (in-memory test doubles) each carry their own cache to avoid cross-instance path collisions. */
|
|
94
|
+
const kScanCache = Symbol("session-listing.scanCache");
|
|
95
|
+
|
|
96
|
+
interface StorageWithScanCache extends SessionStorage {
|
|
97
|
+
[kScanCache]?: SessionScanCache;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getSessionScanCache(storage: SessionStorage): SessionScanCache {
|
|
101
|
+
if (storage instanceof FileSessionStorage) return fileSessionScanCache;
|
|
102
|
+
const holder = storage as StorageWithScanCache;
|
|
103
|
+
if (!holder[kScanCache]) holder[kScanCache] = new LRUCache({ max: SESSION_SCAN_CACHE_MAX });
|
|
104
|
+
return holder[kScanCache];
|
|
105
|
+
}
|
|
106
|
+
|
|
68
107
|
function sanitizeSessionName(value: string | undefined): string | undefined {
|
|
69
108
|
if (!value) return undefined;
|
|
70
109
|
const firstLine = value.split(/\r?\n/)[0] ?? "";
|
|
@@ -355,8 +394,22 @@ async function scanSessionFile(
|
|
|
355
394
|
storage: SessionStorage,
|
|
356
395
|
withStatus: boolean,
|
|
357
396
|
): Promise<SessionInfo | undefined> {
|
|
397
|
+
let stat: SessionStorageStat;
|
|
398
|
+
try {
|
|
399
|
+
stat = storage.statSync(file);
|
|
400
|
+
} catch {
|
|
401
|
+
// Missing/unstatable file: no stat identity to cache under.
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
const cache = getSessionScanCache(storage);
|
|
405
|
+
// `withStatus` changes what a scan reads (tail window) and returns, so the
|
|
406
|
+
// two variants are cached under distinct keys.
|
|
407
|
+
const cacheKey = withStatus ? `s\0${file}` : `h\0${file}`;
|
|
408
|
+
const cached = cache.get(cacheKey);
|
|
409
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
410
|
+
return cached.info ? { ...cached.info } : undefined;
|
|
411
|
+
}
|
|
358
412
|
try {
|
|
359
|
-
const stat = storage.statSync(file);
|
|
360
413
|
const [content, suffix] = await storage.readTextSlices(
|
|
361
414
|
file,
|
|
362
415
|
SESSION_LIST_PREFIX_BYTES,
|
|
@@ -365,7 +418,12 @@ async function scanSessionFile(
|
|
|
365
418
|
const { size, mtime } = stat;
|
|
366
419
|
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
367
420
|
const header = parseSessionListHeader(content, entries);
|
|
368
|
-
if (!header)
|
|
421
|
+
if (!header) {
|
|
422
|
+
// Cache the negative result too: an unparseable file stays unparseable
|
|
423
|
+
// until its stat identity changes.
|
|
424
|
+
cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: undefined });
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
369
427
|
|
|
370
428
|
let parsedMessageCount = 0;
|
|
371
429
|
let firstMessage = "";
|
|
@@ -398,7 +456,7 @@ async function scanSessionFile(
|
|
|
398
456
|
|
|
399
457
|
firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? "";
|
|
400
458
|
const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
|
|
401
|
-
|
|
459
|
+
const info: SessionInfo = {
|
|
402
460
|
path: file,
|
|
403
461
|
id: header.id,
|
|
404
462
|
cwd: header.cwd ?? "",
|
|
@@ -412,6 +470,10 @@ async function scanSessionFile(
|
|
|
412
470
|
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
|
|
413
471
|
status: withStatus ? deriveSessionStatus(suffix) : undefined,
|
|
414
472
|
};
|
|
473
|
+
// The cache keeps its own shallow copy; hits also hand out copies, so
|
|
474
|
+
// callers can never mutate the shared cached object.
|
|
475
|
+
cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: { ...info } });
|
|
476
|
+
return info;
|
|
415
477
|
} catch {
|
|
416
478
|
return undefined;
|
|
417
479
|
}
|