@oh-my-pi/pi-coding-agent 16.1.22 → 16.1.23
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 +29 -1
- package/dist/cli.js +3441 -3300
- package/dist/types/cli/gc-cli.d.ts +58 -0
- package/dist/types/commands/gc.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +54 -0
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/mcp/transports/stdio.d.ts +25 -1
- package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/session/session-listing.d.ts +10 -1
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tools/plan-mode-guard.d.ts +7 -0
- package/package.json +12 -12
- package/scripts/bench-guard.ts +1 -1
- package/src/cli/gc-cli.ts +939 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/gc.ts +46 -0
- package/src/config/settings-schema.ts +45 -0
- package/src/config/settings.ts +44 -6
- package/src/edit/hashline/filesystem.ts +11 -6
- package/src/eval/__tests__/julia-prelude.test.ts +18 -0
- package/src/eval/jl/runner.jl +7 -1
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +6 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/lsp/index.ts +8 -1
- package/src/mcp/oauth-discovery.ts +20 -20
- package/src/mcp/transports/stdio.test.ts +20 -3
- package/src/mcp/transports/stdio.ts +45 -14
- package/src/modes/controllers/input-controller.ts +2 -2
- package/src/modes/controllers/selector-controller.ts +10 -0
- package/src/modes/interactive-mode.ts +26 -9
- package/src/modes/theme/mermaid-rendering.test.ts +53 -0
- package/src/modes/theme/theme.ts +33 -14
- package/src/prompts/system/plan-mode-active.md +9 -0
- package/src/prompts/system/system-prompt.md +2 -0
- package/src/sdk.ts +43 -4
- package/src/session/agent-session.ts +323 -62
- package/src/session/session-listing.ts +35 -2
- package/src/slash-commands/builtin-registry.ts +20 -2
- package/src/system-prompt.ts +4 -0
- package/src/tools/acp-bridge.ts +6 -1
- package/src/tools/plan-mode-guard.ts +26 -13
- package/src/utils/edit-mode.ts +19 -2
- package/src/utils/shell-snapshot.ts +1 -1
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type AgentMessage,
|
|
30
30
|
type AgentState,
|
|
31
31
|
type AgentTool,
|
|
32
|
+
type AgentTurnEndContext,
|
|
32
33
|
AppendOnlyContextManager,
|
|
33
34
|
type AsideMessage,
|
|
34
35
|
type CompactionSummaryMessage,
|
|
@@ -1117,6 +1118,20 @@ function toRestoredQueuedMessage(message: AgentMessage): RestoredQueuedMessage {
|
|
|
1117
1118
|
return { text: queueChipText(message), images: queuedImageContent(message) };
|
|
1118
1119
|
}
|
|
1119
1120
|
|
|
1121
|
+
function mergeLlmCompactionPreserveData(
|
|
1122
|
+
hookPreserveData: Record<string, unknown> | undefined,
|
|
1123
|
+
resultPreserveData: Record<string, unknown> | undefined,
|
|
1124
|
+
): Record<string, unknown> | undefined {
|
|
1125
|
+
const preserveData = { ...(hookPreserveData ?? {}), ...(resultPreserveData ?? {}) };
|
|
1126
|
+
return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
type MessageEndPersistenceSlot = {
|
|
1130
|
+
readonly promise: Promise<void>;
|
|
1131
|
+
persist: (persistMessage: () => void) => Promise<void>;
|
|
1132
|
+
release: () => void;
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1120
1135
|
export class AgentSession {
|
|
1121
1136
|
readonly agent: Agent;
|
|
1122
1137
|
readonly sessionManager: SessionManager;
|
|
@@ -1249,6 +1264,8 @@ export class AgentSession {
|
|
|
1249
1264
|
// Extension system
|
|
1250
1265
|
#extensionRunner: ExtensionRunner | undefined = undefined;
|
|
1251
1266
|
#turnIndex = 0;
|
|
1267
|
+
#messageEndPersistenceTail: Promise<void> = Promise.resolve();
|
|
1268
|
+
#pendingMessageEndPersistence = new Map<string, Promise<void>>();
|
|
1252
1269
|
|
|
1253
1270
|
#skills: Skill[];
|
|
1254
1271
|
#skillWarnings: SkillWarning[];
|
|
@@ -1614,7 +1631,7 @@ export class AgentSession {
|
|
|
1614
1631
|
};
|
|
1615
1632
|
this.agent.setProviderResponseInterceptor(this.#onResponse);
|
|
1616
1633
|
this.agent.setRawSseEventInterceptor(this.#onSseEvent);
|
|
1617
|
-
this.agent.setOnTurnEnd(async (messages, signal) => {
|
|
1634
|
+
this.agent.setOnTurnEnd(async (messages, signal, context) => {
|
|
1618
1635
|
if (signal?.aborted) return;
|
|
1619
1636
|
const rewindReport = this.#extractRewindReport(messages);
|
|
1620
1637
|
if (rewindReport) {
|
|
@@ -1630,7 +1647,7 @@ export class AgentSession {
|
|
|
1630
1647
|
await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
|
|
1631
1648
|
}
|
|
1632
1649
|
}
|
|
1633
|
-
await this.#maintainContextMidRun(messages, signal);
|
|
1650
|
+
await this.#maintainContextMidRun(messages, signal, context);
|
|
1634
1651
|
});
|
|
1635
1652
|
this.yieldQueue = new YieldQueue({
|
|
1636
1653
|
isStreaming: () => this.isStreaming,
|
|
@@ -2501,6 +2518,190 @@ export class AgentSession {
|
|
|
2501
2518
|
}
|
|
2502
2519
|
};
|
|
2503
2520
|
|
|
2521
|
+
#messageValueSignature(value: unknown): string {
|
|
2522
|
+
return JSON.stringify(value) ?? "undefined";
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
#sessionMessagesReferToSameTurn(left: AgentMessage, right: AgentMessage): boolean {
|
|
2526
|
+
if (left === right) return true;
|
|
2527
|
+
if (left.role !== right.role) return false;
|
|
2528
|
+
switch (left.role) {
|
|
2529
|
+
case "assistant":
|
|
2530
|
+
if (right.role !== "assistant") return false;
|
|
2531
|
+
return (
|
|
2532
|
+
left.timestamp === right.timestamp &&
|
|
2533
|
+
left.provider === right.provider &&
|
|
2534
|
+
left.model === right.model &&
|
|
2535
|
+
left.responseId === right.responseId &&
|
|
2536
|
+
left.stopReason === right.stopReason &&
|
|
2537
|
+
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2538
|
+
);
|
|
2539
|
+
case "toolResult":
|
|
2540
|
+
if (right.role !== "toolResult") return false;
|
|
2541
|
+
return (
|
|
2542
|
+
left.timestamp === right.timestamp &&
|
|
2543
|
+
left.toolCallId === right.toolCallId &&
|
|
2544
|
+
left.toolName === right.toolName &&
|
|
2545
|
+
left.isError === right.isError &&
|
|
2546
|
+
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2547
|
+
);
|
|
2548
|
+
case "user":
|
|
2549
|
+
if (right.role !== "user") return false;
|
|
2550
|
+
return (
|
|
2551
|
+
left.timestamp === right.timestamp &&
|
|
2552
|
+
left.attribution === right.attribution &&
|
|
2553
|
+
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2554
|
+
);
|
|
2555
|
+
case "developer":
|
|
2556
|
+
if (right.role !== "developer") return false;
|
|
2557
|
+
return (
|
|
2558
|
+
left.timestamp === right.timestamp &&
|
|
2559
|
+
left.attribution === right.attribution &&
|
|
2560
|
+
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2561
|
+
);
|
|
2562
|
+
case "fileMention":
|
|
2563
|
+
if (right.role !== "fileMention") return false;
|
|
2564
|
+
return (
|
|
2565
|
+
left.timestamp === right.timestamp &&
|
|
2566
|
+
this.#messageValueSignature(left.files) === this.#messageValueSignature(right.files)
|
|
2567
|
+
);
|
|
2568
|
+
default:
|
|
2569
|
+
return false;
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
#sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
|
|
2574
|
+
switch (message.role) {
|
|
2575
|
+
case "assistant":
|
|
2576
|
+
return [
|
|
2577
|
+
"assistant",
|
|
2578
|
+
message.timestamp,
|
|
2579
|
+
message.provider,
|
|
2580
|
+
message.model,
|
|
2581
|
+
message.responseId ?? "",
|
|
2582
|
+
message.stopReason,
|
|
2583
|
+
].join(":");
|
|
2584
|
+
case "toolResult":
|
|
2585
|
+
return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
|
|
2586
|
+
case "user":
|
|
2587
|
+
case "developer":
|
|
2588
|
+
case "fileMention":
|
|
2589
|
+
return `${message.role}:${message.timestamp}`;
|
|
2590
|
+
default:
|
|
2591
|
+
return undefined;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
#createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined {
|
|
2596
|
+
const key = this.#sessionMessagePersistenceKey(message);
|
|
2597
|
+
if (!key) return undefined;
|
|
2598
|
+
const previous = this.#messageEndPersistenceTail;
|
|
2599
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
2600
|
+
const clear = () => {
|
|
2601
|
+
if (this.#pendingMessageEndPersistence.get(key) === promise) {
|
|
2602
|
+
this.#pendingMessageEndPersistence.delete(key);
|
|
2603
|
+
}
|
|
2604
|
+
};
|
|
2605
|
+
this.#pendingMessageEndPersistence.set(key, promise);
|
|
2606
|
+
this.#messageEndPersistenceTail = promise.catch(() => {});
|
|
2607
|
+
return {
|
|
2608
|
+
promise,
|
|
2609
|
+
persist: async persistMessage => {
|
|
2610
|
+
await previous;
|
|
2611
|
+
try {
|
|
2612
|
+
persistMessage();
|
|
2613
|
+
} finally {
|
|
2614
|
+
resolve();
|
|
2615
|
+
clear();
|
|
2616
|
+
}
|
|
2617
|
+
},
|
|
2618
|
+
release: () => {
|
|
2619
|
+
resolve();
|
|
2620
|
+
clear();
|
|
2621
|
+
},
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
async #waitForSessionMessagePersistence(message: AgentMessage): Promise<void> {
|
|
2626
|
+
const key = this.#sessionMessagePersistenceKey(message);
|
|
2627
|
+
if (!key) return;
|
|
2628
|
+
await this.#pendingMessageEndPersistence.get(key);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
#sessionMessageAlreadyPersisted(message: AgentMessage): boolean {
|
|
2632
|
+
const branch = this.sessionManager.getBranch();
|
|
2633
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
2634
|
+
const entry = branch[index];
|
|
2635
|
+
if (entry.type === "message" && this.#sessionMessagesReferToSameTurn(entry.message, message)) return true;
|
|
2636
|
+
}
|
|
2637
|
+
return false;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
#hasPersistedLaterTurnMessage(turnMessages: AgentMessage[], messageIndex: number): boolean {
|
|
2641
|
+
const branch = this.sessionManager.getBranch();
|
|
2642
|
+
for (let index = messageIndex + 1; index < turnMessages.length; index++) {
|
|
2643
|
+
const message = turnMessages[index];
|
|
2644
|
+
if (
|
|
2645
|
+
branch.some(
|
|
2646
|
+
entry => entry.type === "message" && this.#sessionMessagesReferToSameTurn(entry.message, message),
|
|
2647
|
+
)
|
|
2648
|
+
) {
|
|
2649
|
+
return true;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return false;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
#persistSessionMessageIfMissing(message: AgentMessage): void {
|
|
2656
|
+
if (
|
|
2657
|
+
message.role !== "user" &&
|
|
2658
|
+
message.role !== "developer" &&
|
|
2659
|
+
message.role !== "assistant" &&
|
|
2660
|
+
message.role !== "toolResult" &&
|
|
2661
|
+
message.role !== "fileMention"
|
|
2662
|
+
) {
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
if (this.#sessionMessageAlreadyPersisted(message)) return;
|
|
2666
|
+
if (message.role === "assistant") {
|
|
2667
|
+
const assistantMsg = message as AssistantMessage;
|
|
2668
|
+
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
|
2669
|
+
assistantMsg.contextSnapshot = {
|
|
2670
|
+
promptTokens: calculatePromptTokens(assistantMsg.usage),
|
|
2671
|
+
nonMessageTokens: this.#pendingContextSnapshot?.nonMessageTokens ?? computeNonMessageTokens(this),
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
const skipPersistedRewindResult =
|
|
2676
|
+
message.role === "toolResult" &&
|
|
2677
|
+
message.toolName === "rewind" &&
|
|
2678
|
+
this.#rewoundToolResultIds.delete(message.toolCallId);
|
|
2679
|
+
if (!skipPersistedRewindResult) {
|
|
2680
|
+
this.sessionManager.appendMessage(message);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
async #persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise<boolean> {
|
|
2685
|
+
if (!context) return true;
|
|
2686
|
+
const turnMessages = [context.message, ...context.toolResults];
|
|
2687
|
+
for (const message of turnMessages) {
|
|
2688
|
+
await this.#waitForSessionMessagePersistence(message);
|
|
2689
|
+
}
|
|
2690
|
+
for (let index = 0; index < turnMessages.length; index++) {
|
|
2691
|
+
const message = turnMessages[index];
|
|
2692
|
+
if (this.#sessionMessageAlreadyPersisted(message)) continue;
|
|
2693
|
+
if (this.#hasPersistedLaterTurnMessage(turnMessages, index)) {
|
|
2694
|
+
logger.debug("Skipping mid-run compaction because turn persistence is out of order", {
|
|
2695
|
+
role: message.role,
|
|
2696
|
+
timestamp: message.timestamp,
|
|
2697
|
+
});
|
|
2698
|
+
return false;
|
|
2699
|
+
}
|
|
2700
|
+
this.#persistSessionMessageIfMissing(message);
|
|
2701
|
+
}
|
|
2702
|
+
return true;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2504
2705
|
#processAgentEvent = async (event: AgentEvent): Promise<void> => {
|
|
2505
2706
|
// Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the
|
|
2506
2707
|
// persisted message BEFORE the obfuscator's display-side copy below.
|
|
@@ -2522,6 +2723,9 @@ export class AgentSession {
|
|
|
2522
2723
|
this.#planInternalAbortPending = false;
|
|
2523
2724
|
}
|
|
2524
2725
|
|
|
2726
|
+
const messageEndPersistence =
|
|
2727
|
+
event.type === "message_end" ? this.#createMessageEndPersistenceSlot(event.message) : undefined;
|
|
2728
|
+
|
|
2525
2729
|
// Deobfuscate assistant message content for display emission — the LLM echoes back
|
|
2526
2730
|
// obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
|
|
2527
2731
|
// values. The original event.message stays obfuscated so the persistence path below
|
|
@@ -2547,7 +2751,12 @@ export class AgentSession {
|
|
|
2547
2751
|
});
|
|
2548
2752
|
}
|
|
2549
2753
|
|
|
2550
|
-
|
|
2754
|
+
try {
|
|
2755
|
+
await this.#emitSessionEvent(displayEvent);
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
messageEndPersistence?.release();
|
|
2758
|
+
throw error;
|
|
2759
|
+
}
|
|
2551
2760
|
|
|
2552
2761
|
if (event.type === "turn_start") {
|
|
2553
2762
|
this.#resetStreamingEditState();
|
|
@@ -2633,43 +2842,28 @@ export class AgentSession {
|
|
|
2633
2842
|
|
|
2634
2843
|
// Handle session persistence
|
|
2635
2844
|
if (event.type === "message_end") {
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
} else if (
|
|
2650
|
-
event.message.role === "user" ||
|
|
2651
|
-
event.message.role === "developer" ||
|
|
2652
|
-
event.message.role === "assistant" ||
|
|
2653
|
-
event.message.role === "toolResult" ||
|
|
2654
|
-
event.message.role === "fileMention"
|
|
2655
|
-
) {
|
|
2656
|
-
// Regular LLM message - persist as SessionMessageEntry
|
|
2657
|
-
if (event.message.role === "assistant") {
|
|
2658
|
-
const assistantMsg = event.message as AssistantMessage;
|
|
2659
|
-
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
|
2660
|
-
assistantMsg.contextSnapshot = {
|
|
2661
|
-
promptTokens: calculatePromptTokens(assistantMsg.usage),
|
|
2662
|
-
nonMessageTokens: this.#pendingContextSnapshot?.nonMessageTokens ?? computeNonMessageTokens(this),
|
|
2663
|
-
};
|
|
2845
|
+
const persistMessageEnd = () => {
|
|
2846
|
+
// Check if this is a hook/custom message
|
|
2847
|
+
if (event.message.role === "hookMessage" || event.message.role === "custom") {
|
|
2848
|
+
// Persist as CustomMessageEntry
|
|
2849
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
2850
|
+
event.message.customType,
|
|
2851
|
+
event.message.content,
|
|
2852
|
+
event.message.display,
|
|
2853
|
+
event.message.details,
|
|
2854
|
+
event.message.attribution ?? "agent",
|
|
2855
|
+
);
|
|
2856
|
+
if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
|
|
2857
|
+
this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
|
|
2664
2858
|
}
|
|
2859
|
+
} else {
|
|
2860
|
+
this.#persistSessionMessageIfMissing(event.message);
|
|
2665
2861
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
this.sessionManager.appendMessage(event.message);
|
|
2672
|
-
}
|
|
2862
|
+
};
|
|
2863
|
+
if (messageEndPersistence) {
|
|
2864
|
+
await messageEndPersistence.persist(persistMessageEnd);
|
|
2865
|
+
} else {
|
|
2866
|
+
persistMessageEnd();
|
|
2673
2867
|
}
|
|
2674
2868
|
// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
|
|
2675
2869
|
|
|
@@ -5352,11 +5546,48 @@ export class AgentSession {
|
|
|
5352
5546
|
}
|
|
5353
5547
|
|
|
5354
5548
|
#obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
|
|
5355
|
-
if (!this.#obfuscator?.hasSecrets()
|
|
5356
|
-
|
|
5357
|
-
// `
|
|
5358
|
-
//
|
|
5359
|
-
|
|
5549
|
+
if (!this.#obfuscator?.hasSecrets()) return preparation;
|
|
5550
|
+
const previousSummary = this.#obfuscateTextForProvider(preparation.previousSummary);
|
|
5551
|
+
// `compact()` folds the prior snapcompact archive's plaintext into the
|
|
5552
|
+
// summarization prompt on the snapcompact→context-full transition, so the
|
|
5553
|
+
// archive's text regions must be redacted alongside the summary. Only the
|
|
5554
|
+
// `snapcompact` slot's text is rewritten; every other preserveData key —
|
|
5555
|
+
// notably the OpenAI remote-compaction `encrypted_content` replay state — is
|
|
5556
|
+
// opaque provider-replay data and stays byte-identical.
|
|
5557
|
+
const previousPreserveData = this.#obfuscatePreservedArchiveText(preparation.previousPreserveData);
|
|
5558
|
+
if (
|
|
5559
|
+
previousSummary === preparation.previousSummary &&
|
|
5560
|
+
previousPreserveData === preparation.previousPreserveData
|
|
5561
|
+
) {
|
|
5562
|
+
return preparation;
|
|
5563
|
+
}
|
|
5564
|
+
return { ...preparation, previousSummary, previousPreserveData };
|
|
5565
|
+
}
|
|
5566
|
+
|
|
5567
|
+
/** Redact secrets in the persisted snapcompact archive's plaintext regions
|
|
5568
|
+
* ({@link snapcompact.archiveSourceText}'s `text`/`textHead`/`textTail`) so the
|
|
5569
|
+
* snapcompact→context-full migration in `compact()` cannot ship raw archived
|
|
5570
|
+
* user/tool text to the provider. Frames and every non-`snapcompact` key pass
|
|
5571
|
+
* through byte-identical; the same reference is returned when nothing changes. */
|
|
5572
|
+
#obfuscatePreservedArchiveText(
|
|
5573
|
+
preserveData: Record<string, unknown> | undefined,
|
|
5574
|
+
): Record<string, unknown> | undefined {
|
|
5575
|
+
const obfuscator = this.#obfuscator;
|
|
5576
|
+
if (!obfuscator?.hasSecrets() || !preserveData || !snapcompact.getPreservedArchive(preserveData)) {
|
|
5577
|
+
return preserveData;
|
|
5578
|
+
}
|
|
5579
|
+
const slot = preserveData[snapcompact.PRESERVE_KEY] as Record<string, unknown>;
|
|
5580
|
+
const obfuscated: Record<string, unknown> = { ...slot };
|
|
5581
|
+
let changed = false;
|
|
5582
|
+
for (const key of ["text", "textHead", "textTail"] as const) {
|
|
5583
|
+
const value = slot[key];
|
|
5584
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
5585
|
+
const next = obfuscator.obfuscate(value);
|
|
5586
|
+
if (next === value) continue;
|
|
5587
|
+
obfuscated[key] = next;
|
|
5588
|
+
changed = true;
|
|
5589
|
+
}
|
|
5590
|
+
return changed ? { ...preserveData, [snapcompact.PRESERVE_KEY]: obfuscated } : preserveData;
|
|
5360
5591
|
}
|
|
5361
5592
|
|
|
5362
5593
|
#deobfuscateFromProvider(text: string): string {
|
|
@@ -5695,6 +5926,7 @@ export class AgentSession {
|
|
|
5695
5926
|
askToolName: "ask",
|
|
5696
5927
|
writeToolName: "write",
|
|
5697
5928
|
editToolName: "edit",
|
|
5929
|
+
isHashlineEditMode: this.#resolveActiveEditMode() === "hashline",
|
|
5698
5930
|
reentry: state.reentry ?? false,
|
|
5699
5931
|
iterative: state.workflow === "iterative",
|
|
5700
5932
|
});
|
|
@@ -8020,7 +8252,7 @@ export class AgentSession {
|
|
|
8020
8252
|
firstKeptEntryId = result.firstKeptEntryId;
|
|
8021
8253
|
tokensBefore = result.tokensBefore;
|
|
8022
8254
|
details = result.details;
|
|
8023
|
-
preserveData =
|
|
8255
|
+
preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, result.preserveData);
|
|
8024
8256
|
} catch (err) {
|
|
8025
8257
|
if (err instanceof CompactionCancelledError) {
|
|
8026
8258
|
throw err;
|
|
@@ -8398,32 +8630,50 @@ export class AgentSession {
|
|
|
8398
8630
|
}
|
|
8399
8631
|
|
|
8400
8632
|
/**
|
|
8401
|
-
* Compact
|
|
8633
|
+
* Compact continuing tool-loop runs before the next provider request.
|
|
8402
8634
|
*
|
|
8403
|
-
*
|
|
8404
|
-
*
|
|
8405
|
-
*
|
|
8406
|
-
*
|
|
8407
|
-
*
|
|
8408
|
-
*
|
|
8409
|
-
*
|
|
8410
|
-
*/
|
|
8411
|
-
async #maintainContextMidRun(
|
|
8412
|
-
|
|
8413
|
-
|
|
8635
|
+
* `onTurnEnd` is the safe boundary: tool results for the just-finished turn
|
|
8636
|
+
* are already paired in `activeMessages`, the live array the agent loop reads
|
|
8637
|
+
* before its next model call. Before compacting, the just-finished turn is
|
|
8638
|
+
* synchronously persisted if async message hooks have not reached the normal
|
|
8639
|
+
* append path yet. Mid-run handoff is suppressed because resetting the session
|
|
8640
|
+
* while the loop owns `activeMessages` would race the next request; handoff
|
|
8641
|
+
* strategy falls back to in-place context-full compaction here.
|
|
8642
|
+
*/
|
|
8643
|
+
async #maintainContextMidRun(
|
|
8644
|
+
activeMessages: AgentMessage[],
|
|
8645
|
+
signal: AbortSignal | undefined,
|
|
8646
|
+
context: AgentTurnEndContext | undefined,
|
|
8647
|
+
): Promise<void> {
|
|
8648
|
+
if (
|
|
8649
|
+
signal?.aborted ||
|
|
8650
|
+
this.#isDisposed ||
|
|
8651
|
+
this.isCompacting ||
|
|
8652
|
+
this.isGeneratingHandoff ||
|
|
8653
|
+
!context?.willContinue
|
|
8654
|
+
)
|
|
8655
|
+
return;
|
|
8414
8656
|
|
|
8415
8657
|
const model = this.model;
|
|
8416
8658
|
const contextWindow = model?.contextWindow ?? 0;
|
|
8417
8659
|
if (contextWindow <= 0) return;
|
|
8418
8660
|
|
|
8419
8661
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
8420
|
-
if (
|
|
8662
|
+
if (
|
|
8663
|
+
!compactionSettings.enabled ||
|
|
8664
|
+
compactionSettings.strategy === "off" ||
|
|
8665
|
+
compactionSettings.midTurnEnabled === false
|
|
8666
|
+
) {
|
|
8667
|
+
return;
|
|
8668
|
+
}
|
|
8421
8669
|
|
|
8422
8670
|
const lastAssistant = [...activeMessages]
|
|
8423
8671
|
.reverse()
|
|
8424
8672
|
.find((message): message is AssistantMessage => message.role === "assistant");
|
|
8425
8673
|
if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
|
|
8426
8674
|
|
|
8675
|
+
if (!(await this.#persistTurnMessagesForMidRunCompaction(context))) return;
|
|
8676
|
+
|
|
8427
8677
|
const billedContextTokens = calculateContextTokens(lastAssistant.usage);
|
|
8428
8678
|
const storedContextTokens = this.#estimateStoredContextTokens();
|
|
8429
8679
|
const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
|
|
@@ -8433,6 +8683,7 @@ export class AgentSession {
|
|
|
8433
8683
|
await this.#runAutoCompaction("threshold", false, false, false, {
|
|
8434
8684
|
autoContinue: false,
|
|
8435
8685
|
suppressContinuation: true,
|
|
8686
|
+
suppressHandoff: true,
|
|
8436
8687
|
triggerContextTokens: contextTokens,
|
|
8437
8688
|
});
|
|
8438
8689
|
|
|
@@ -8441,10 +8692,11 @@ export class AgentSession {
|
|
|
8441
8692
|
if (compactedMessages !== activeMessages) {
|
|
8442
8693
|
activeMessages.splice(0, activeMessages.length, ...compactedMessages);
|
|
8443
8694
|
}
|
|
8444
|
-
logger.debug("Mid-run
|
|
8695
|
+
logger.debug("Mid-run compaction ran between provider calls", {
|
|
8445
8696
|
contextTokens,
|
|
8446
8697
|
contextWindow,
|
|
8447
8698
|
strategy: compactionSettings.strategy,
|
|
8699
|
+
goalActive: this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active",
|
|
8448
8700
|
messagesBefore,
|
|
8449
8701
|
messagesAfter: activeMessages.length,
|
|
8450
8702
|
});
|
|
@@ -9839,7 +10091,12 @@ export class AgentSession {
|
|
|
9839
10091
|
willRetry: boolean,
|
|
9840
10092
|
deferred = false,
|
|
9841
10093
|
allowDefer = true,
|
|
9842
|
-
options: {
|
|
10094
|
+
options: {
|
|
10095
|
+
autoContinue?: boolean;
|
|
10096
|
+
triggerContextTokens?: number;
|
|
10097
|
+
suppressContinuation?: boolean;
|
|
10098
|
+
suppressHandoff?: boolean;
|
|
10099
|
+
} = {},
|
|
9843
10100
|
): Promise<CompactionCheckResult> {
|
|
9844
10101
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
9845
10102
|
if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
|
|
@@ -9848,6 +10105,7 @@ export class AgentSession {
|
|
|
9848
10105
|
const suppressContinuation = options.suppressContinuation === true;
|
|
9849
10106
|
const shouldAutoContinue =
|
|
9850
10107
|
!suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
|
|
10108
|
+
const suppressHandoff = options.suppressHandoff === true;
|
|
9851
10109
|
// Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
|
|
9852
10110
|
// reclaims nothing we fall through to the summary-compaction body below so
|
|
9853
10111
|
// the oversized input still gets resolved.
|
|
@@ -9866,6 +10124,7 @@ export class AgentSession {
|
|
|
9866
10124
|
// paths the caller wants resolved before scheduling the next turn. "idle" is
|
|
9867
10125
|
// triggered by the idle loop and does its own scheduling.
|
|
9868
10126
|
if (
|
|
10127
|
+
!suppressHandoff &&
|
|
9869
10128
|
!deferred &&
|
|
9870
10129
|
allowDefer &&
|
|
9871
10130
|
reason !== "overflow" &&
|
|
@@ -9890,7 +10149,9 @@ export class AgentSession {
|
|
|
9890
10149
|
// safe for every reason (it makes no LLM call at all) but requires a vision
|
|
9891
10150
|
// model to be worth anything — fall back to context-full otherwise.
|
|
9892
10151
|
let action: "context-full" | "handoff" | "snapcompact" =
|
|
9893
|
-
compactionSettings.strategy === "handoff" && reason !== "overflow"
|
|
10152
|
+
compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
|
|
10153
|
+
? "handoff"
|
|
10154
|
+
: "context-full";
|
|
9894
10155
|
if (compactionSettings.strategy === "snapcompact") {
|
|
9895
10156
|
if (this.model?.input.includes("image")) {
|
|
9896
10157
|
action = "snapcompact";
|
|
@@ -9917,7 +10178,7 @@ export class AgentSession {
|
|
|
9917
10178
|
// a message typed as the compaction loader appears must land in the compaction
|
|
9918
10179
|
// queue, not the core steering queue (which handoff's agent.reset() would wipe).
|
|
9919
10180
|
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9920
|
-
if (
|
|
10181
|
+
if (action === "handoff") {
|
|
9921
10182
|
const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
|
|
9922
10183
|
const handoffResult = await this.handoff(handoffFocus, {
|
|
9923
10184
|
autoTriggered: true,
|
|
@@ -10246,7 +10507,7 @@ export class AgentSession {
|
|
|
10246
10507
|
firstKeptEntryId = compactResult.firstKeptEntryId;
|
|
10247
10508
|
tokensBefore = compactResult.tokensBefore;
|
|
10248
10509
|
details = compactResult.details;
|
|
10249
|
-
preserveData =
|
|
10510
|
+
preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, compactResult.preserveData);
|
|
10250
10511
|
}
|
|
10251
10512
|
|
|
10252
10513
|
if (autoCompactionSignal.aborted) {
|
|
@@ -495,6 +495,19 @@ async function scanSessionDir(
|
|
|
495
495
|
}
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
+
async function scanSessionDirReadOnly(
|
|
499
|
+
sessionDir: string,
|
|
500
|
+
storage: SessionStorage,
|
|
501
|
+
withStatus: boolean,
|
|
502
|
+
): Promise<SessionInfo[]> {
|
|
503
|
+
try {
|
|
504
|
+
const files = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
505
|
+
return await collectSessionsFromFiles(files, storage, withStatus);
|
|
506
|
+
} catch {
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
498
511
|
/**
|
|
499
512
|
* List sessions in a resolved session directory (newest first), reading each
|
|
500
513
|
* file's lifecycle {@link SessionStatus}.
|
|
@@ -503,6 +516,13 @@ export function listSessions(sessionDir: string, storage: SessionStorage): Promi
|
|
|
503
516
|
return scanSessionDir(sessionDir, storage, true);
|
|
504
517
|
}
|
|
505
518
|
|
|
519
|
+
/**
|
|
520
|
+
* List sessions without repairing orphaned backups or mutating the directory.
|
|
521
|
+
*/
|
|
522
|
+
export function listSessionsReadOnly(sessionDir: string, storage: SessionStorage): Promise<SessionInfo[]> {
|
|
523
|
+
return scanSessionDirReadOnly(sessionDir, storage, true);
|
|
524
|
+
}
|
|
525
|
+
|
|
506
526
|
/** List all sessions across all project directories (newest first). */
|
|
507
527
|
export async function listAllSessions(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
508
528
|
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
@@ -561,12 +581,25 @@ function sessionMatchesResumeArg(session: SessionInfo, sessionArg: string): bool
|
|
|
561
581
|
return fileSessionId.startsWith(normalizedArg);
|
|
562
582
|
}
|
|
563
583
|
|
|
584
|
+
/** Controls cross-directory fallback for resumable session lookup. */
|
|
585
|
+
export interface ResolveResumableSessionOptions {
|
|
586
|
+
/** Search default global session buckets after the active/custom session directory misses. */
|
|
587
|
+
allowGlobalFallback?: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function isSessionStorage(value: SessionStorage | ResolveResumableSessionOptions): value is SessionStorage {
|
|
591
|
+
return "listFilesSync" in value;
|
|
592
|
+
}
|
|
593
|
+
|
|
564
594
|
export async function resolveResumableSession(
|
|
565
595
|
sessionArg: string,
|
|
566
596
|
cwd: string,
|
|
567
597
|
sessionDir?: string,
|
|
568
|
-
|
|
598
|
+
storageOrOptions: SessionStorage | ResolveResumableSessionOptions = new FileSessionStorage(),
|
|
599
|
+
options: ResolveResumableSessionOptions = {},
|
|
569
600
|
): Promise<ResolvedSessionMatch | undefined> {
|
|
601
|
+
const storage = isSessionStorage(storageOrOptions) ? storageOrOptions : new FileSessionStorage();
|
|
602
|
+
const resolvedOptions = isSessionStorage(storageOrOptions) ? options : storageOrOptions;
|
|
570
603
|
const localSessionDir = sessionDir ?? computeDefaultSessionDir(cwd, storage);
|
|
571
604
|
const localSessions = await listSessions(localSessionDir, storage);
|
|
572
605
|
const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
@@ -574,7 +607,7 @@ export async function resolveResumableSession(
|
|
|
574
607
|
return { session: localMatch, scope: "local" };
|
|
575
608
|
}
|
|
576
609
|
|
|
577
|
-
if (sessionDir) {
|
|
610
|
+
if (sessionDir && resolvedOptions.allowGlobalFallback !== true) {
|
|
578
611
|
return undefined;
|
|
579
612
|
}
|
|
580
613
|
|
|
@@ -27,6 +27,7 @@ import { theme } from "../modes/theme/theme";
|
|
|
27
27
|
import type { InteractiveModeContext } from "../modes/types";
|
|
28
28
|
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
|
|
29
29
|
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
|
|
30
|
+
import { resolveResumableSession } from "../session/session-listing";
|
|
30
31
|
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
|
|
31
32
|
import { urlHyperlinkAlways } from "../tui";
|
|
32
33
|
import { getChangelogPath, parseChangelog } from "../utils/changelog";
|
|
@@ -1414,9 +1415,26 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1414
1415
|
{
|
|
1415
1416
|
name: "resume",
|
|
1416
1417
|
description: "Resume a different session",
|
|
1417
|
-
|
|
1418
|
-
|
|
1418
|
+
inlineHint: "[session id]",
|
|
1419
|
+
allowArgs: true,
|
|
1420
|
+
handleTui: async (command, runtime) => {
|
|
1421
|
+
const sessionArg = command.args.trim();
|
|
1419
1422
|
runtime.ctx.editor.setText("");
|
|
1423
|
+
if (!sessionArg) {
|
|
1424
|
+
runtime.ctx.showSessionSelector();
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const match = await resolveResumableSession(
|
|
1428
|
+
sessionArg,
|
|
1429
|
+
runtime.ctx.sessionManager.getCwd(),
|
|
1430
|
+
runtime.ctx.sessionManager.getSessionDir(),
|
|
1431
|
+
{ allowGlobalFallback: true },
|
|
1432
|
+
);
|
|
1433
|
+
if (!match) {
|
|
1434
|
+
runtime.ctx.showError(`Session "${sessionArg}" not found`);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
await runtime.ctx.handleResumeSession(match.session.path);
|
|
1420
1438
|
},
|
|
1421
1439
|
},
|
|
1422
1440
|
{
|
package/src/system-prompt.ts
CHANGED
|
@@ -423,6 +423,8 @@ export interface BuildSystemPromptOptions {
|
|
|
423
423
|
personality?: Personality;
|
|
424
424
|
/** Whether to include the workspace directory tree in the system prompt. Default: false */
|
|
425
425
|
includeWorkspaceTree?: boolean;
|
|
426
|
+
/** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */
|
|
427
|
+
renderMermaid?: boolean;
|
|
426
428
|
}
|
|
427
429
|
|
|
428
430
|
/** Result of building provider-facing system prompt messages. */
|
|
@@ -464,6 +466,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
464
466
|
model,
|
|
465
467
|
personality = "default",
|
|
466
468
|
includeWorkspaceTree = false,
|
|
469
|
+
renderMermaid = true,
|
|
467
470
|
} = options;
|
|
468
471
|
const inlineToolDescriptors = providedInlineToolDescriptors ?? false;
|
|
469
472
|
const resolvedCwd = cwd ?? getProjectDir();
|
|
@@ -682,6 +685,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
682
685
|
hasMemoryRoot: memoryRootEnabled,
|
|
683
686
|
hasObsidian: hasObsidian(),
|
|
684
687
|
includeWorkspaceTree,
|
|
688
|
+
renderMermaid,
|
|
685
689
|
};
|
|
686
690
|
const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
|
|
687
691
|
const systemPrompt = [rendered];
|