@pi-vault/pi-dcp 0.1.0

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.
@@ -0,0 +1,102 @@
1
+ import type {
2
+ MessageIdState,
3
+ Nudges,
4
+ Prune,
5
+ PruneMessagesState,
6
+ SessionState,
7
+ SessionStats,
8
+ } from "./types.ts";
9
+
10
+ export function createSessionState(): SessionState {
11
+ return {
12
+ sessionId: null,
13
+ manualMode: false,
14
+ compressPermission: undefined,
15
+ pendingManualTrigger: null,
16
+ prune: createPrune(),
17
+ nudges: createNudges(),
18
+ stats: createStats(),
19
+ toolParameters: new Map(),
20
+ toolIdList: [],
21
+ messageIds: createMessageIdState(),
22
+ lastCompaction: 0,
23
+ currentTurn: 0,
24
+ modelContextWindow: undefined,
25
+ };
26
+ }
27
+
28
+ export function resetSessionState(state: SessionState): void {
29
+ state.sessionId = null;
30
+ state.manualMode = false;
31
+ state.compressPermission = undefined;
32
+ state.pendingManualTrigger = null;
33
+ state.prune.tools.clear();
34
+ resetPruneMessages(state.prune.messages);
35
+ state.nudges.contextLimitAnchors.clear();
36
+ state.nudges.turnAnchors.clear();
37
+ state.nudges.iterationAnchors.clear();
38
+ state.stats.pruneTokenCounter = 0;
39
+ state.stats.totalPruneTokens = 0;
40
+ state.stats.toolsPruned = 0;
41
+ state.stats.messagesCompressed = 0;
42
+ state.toolParameters.clear();
43
+ state.toolIdList = [];
44
+ state.messageIds.byIndex.clear();
45
+ state.messageIds.byRef.clear();
46
+ state.messageIds.nextRefIndex = 1;
47
+ state.lastCompaction = 0;
48
+ state.currentTurn = 0;
49
+ state.modelContextWindow = undefined;
50
+ }
51
+
52
+ function createPrune(): Prune {
53
+ return {
54
+ tools: new Map(),
55
+ messages: createPruneMessages(),
56
+ };
57
+ }
58
+
59
+ function createPruneMessages(): PruneMessagesState {
60
+ return {
61
+ byMessageIndex: new Map(),
62
+ blocksById: new Map(),
63
+ activeBlockIds: new Set(),
64
+ activeByAnchorIndex: new Map(),
65
+ nextBlockId: 1,
66
+ nextRunId: 1,
67
+ };
68
+ }
69
+
70
+ function resetPruneMessages(m: PruneMessagesState): void {
71
+ m.byMessageIndex.clear();
72
+ m.blocksById.clear();
73
+ m.activeBlockIds.clear();
74
+ m.activeByAnchorIndex.clear();
75
+ m.nextBlockId = 1;
76
+ m.nextRunId = 1;
77
+ }
78
+
79
+ function createNudges(): Nudges {
80
+ return {
81
+ contextLimitAnchors: new Set(),
82
+ turnAnchors: new Set(),
83
+ iterationAnchors: new Set(),
84
+ };
85
+ }
86
+
87
+ function createStats(): SessionStats {
88
+ return {
89
+ pruneTokenCounter: 0,
90
+ totalPruneTokens: 0,
91
+ toolsPruned: 0,
92
+ messagesCompressed: 0,
93
+ };
94
+ }
95
+
96
+ function createMessageIdState(): MessageIdState {
97
+ return {
98
+ byIndex: new Map(),
99
+ byRef: new Map(),
100
+ nextRefIndex: 1,
101
+ };
102
+ }
@@ -0,0 +1,87 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { SessionState, ToolParameterEntry } from "./types.ts";
3
+
4
+ /**
5
+ * Scan messages and populate state.toolParameters with metadata for each tool call.
6
+ * Called on every context event to keep the cache current.
7
+ *
8
+ * Pi message model:
9
+ * - Tool calls are in `assistant` messages: content[].type === "toolCall"
10
+ * - Tool results are separate `toolResult` messages with toolCallId, isError
11
+ */
12
+ export function syncToolCache(
13
+ state: SessionState,
14
+ messages: AgentMessage[],
15
+ ): void {
16
+ // First pass: collect tool results
17
+ const resultsByCallId = new Map<string, { isError: boolean; errorText?: string }>();
18
+ for (const msg of messages) {
19
+ if (msg.role !== "toolResult") continue;
20
+ resultsByCallId.set(msg.toolCallId, {
21
+ isError: msg.isError,
22
+ errorText: msg.isError ? extractToolResultText(msg) : undefined,
23
+ });
24
+ }
25
+
26
+ // Second pass: collect tool calls from assistant messages
27
+ for (const msg of messages) {
28
+ if (msg.role !== "assistant") continue;
29
+ if (!Array.isArray(msg.content)) continue;
30
+
31
+ for (const part of msg.content) {
32
+ if (typeof part !== "object" || part === null) continue;
33
+ const p = part as unknown as Record<string, unknown>;
34
+ if (p.type !== "toolCall" || typeof p.id !== "string") continue;
35
+
36
+ const callId = p.id as string;
37
+ if (state.toolParameters.has(callId)) continue;
38
+
39
+ const result = resultsByCallId.get(callId);
40
+ const entry: ToolParameterEntry = {
41
+ tool: (p.name as string) ?? "unknown",
42
+ parameters: p.arguments ?? {},
43
+ status: result ? (result.isError ? "error" : "completed") : "pending",
44
+ error: result?.errorText,
45
+ turn: state.currentTurn,
46
+ tokenCount: undefined,
47
+ };
48
+
49
+ state.toolParameters.set(callId, entry);
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Build ordered list of tool call IDs from messages.
56
+ */
57
+ export function buildToolIdList(
58
+ state: SessionState,
59
+ messages: AgentMessage[],
60
+ ): void {
61
+ const ids: string[] = [];
62
+ for (const msg of messages) {
63
+ if (msg.role !== "assistant") continue;
64
+ if (!Array.isArray(msg.content)) continue;
65
+
66
+ for (const part of msg.content) {
67
+ if (typeof part !== "object" || part === null) continue;
68
+ const p = part as unknown as Record<string, unknown>;
69
+ if (p.type === "toolCall" && typeof p.id === "string") {
70
+ ids.push(p.id as string);
71
+ }
72
+ }
73
+ }
74
+ state.toolIdList = ids;
75
+ }
76
+
77
+ function extractToolResultText(msg: AgentMessage): string | undefined {
78
+ if (msg.role !== "toolResult") return undefined;
79
+ if (!Array.isArray(msg.content)) return undefined;
80
+ const texts: string[] = [];
81
+ for (const part of msg.content) {
82
+ if (typeof part === "object" && part !== null && (part as unknown as Record<string, unknown>).type === "text") {
83
+ texts.push((part as unknown as Record<string, unknown>).text as string);
84
+ }
85
+ }
86
+ return texts.join("\n") || undefined;
87
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * DCP session state types.
3
+ *
4
+ * Adapted from OpenCode DCP for Pi's AgentMessage-based message model.
5
+ * All Maps/Sets are used in-memory; persistence serializes to plain objects.
6
+ */
7
+
8
+ export interface SessionState {
9
+ /** Current session identifier (set on session_start). */
10
+ sessionId: string | null;
11
+ /** Manual mode: false = auto, "active" = manual, "compress-pending" = trigger queued. */
12
+ manualMode: false | "active" | "compress-pending";
13
+ /** Effective compress permission for this session. */
14
+ compressPermission: "allow" | "deny" | undefined;
15
+ /** Pending manual compress trigger. */
16
+ pendingManualTrigger: PendingManualTrigger | null;
17
+ /** Pruning state (tools + message compression). */
18
+ prune: Prune;
19
+ /** Nudge anchor tracking. */
20
+ nudges: Nudges;
21
+ /** Token savings statistics. */
22
+ stats: SessionStats;
23
+ /** Tool parameter cache for deduplication and purge-errors. */
24
+ toolParameters: Map<string, ToolParameterEntry>;
25
+ /** Ordered list of tool call IDs in current context. */
26
+ toolIdList: string[];
27
+ /** Message ID assignment state. */
28
+ messageIds: MessageIdState;
29
+ /** Timestamp of last compaction detected. */
30
+ lastCompaction: number;
31
+ /** Current conversation turn number. */
32
+ currentTurn: number;
33
+ /** Model context window size (from Pi's ctx.getContextUsage). */
34
+ modelContextWindow: number | undefined;
35
+ }
36
+
37
+ export interface PendingManualTrigger {
38
+ sessionId: string;
39
+ prompt: string;
40
+ }
41
+
42
+ export interface Prune {
43
+ /** Tool call IDs marked for output pruning, mapped to estimated token count. */
44
+ tools: Map<string, number>;
45
+ /** Compression block state. */
46
+ messages: PruneMessagesState;
47
+ }
48
+
49
+ export interface PruneMessagesState {
50
+ /** Per-message compression metadata. */
51
+ byMessageIndex: Map<number, PrunedMessageEntry>;
52
+ /** All compression blocks by ID. */
53
+ blocksById: Map<number, CompressionBlock>;
54
+ /** Currently active block IDs. */
55
+ activeBlockIds: Set<number>;
56
+ /** Anchor message index -> active block ID. */
57
+ activeByAnchorIndex: Map<number, number>;
58
+ /** Next block ID to allocate. */
59
+ nextBlockId: number;
60
+ /** Next run ID to allocate. */
61
+ nextRunId: number;
62
+ }
63
+
64
+ export interface PrunedMessageEntry {
65
+ /** Estimated token count of the original message. */
66
+ tokenCount: number;
67
+ /** Block IDs that cover this message (may have multiple from nesting). */
68
+ blockIds: number[];
69
+ /** Block IDs that are currently active on this message. */
70
+ activeBlockIds: number[];
71
+ }
72
+
73
+ export interface CompressionBlock {
74
+ blockId: number;
75
+ runId: number;
76
+ active: boolean;
77
+ deactivatedByUser: boolean;
78
+ compressedTokens: number;
79
+ summaryTokens: number;
80
+ durationMs: number;
81
+ mode: "range" | "message" | undefined;
82
+ topic: string;
83
+ batchTopic: string | undefined;
84
+ startIndex: number;
85
+ endIndex: number;
86
+ anchorIndex: number;
87
+ compressMessageIndex: number;
88
+ includedBlockIds: number[];
89
+ consumedBlockIds: number[];
90
+ parentBlockIds: number[];
91
+ directMessageIndices: number[];
92
+ directToolIds: string[];
93
+ effectiveMessageIndices: number[];
94
+ effectiveToolIds: string[];
95
+ createdAt: number;
96
+ deactivatedAt: number | undefined;
97
+ deactivatedByBlockId: number | undefined;
98
+ summary: string;
99
+ }
100
+
101
+ export interface ToolParameterEntry {
102
+ tool: string;
103
+ parameters: unknown;
104
+ status: "pending" | "running" | "completed" | "error" | undefined;
105
+ error: string | undefined;
106
+ turn: number;
107
+ tokenCount: number | undefined;
108
+ }
109
+
110
+ export interface Nudges {
111
+ contextLimitAnchors: Set<number>;
112
+ turnAnchors: Set<number>;
113
+ iterationAnchors: Set<number>;
114
+ }
115
+
116
+ export interface SessionStats {
117
+ pruneTokenCounter: number;
118
+ totalPruneTokens: number;
119
+ toolsPruned: number;
120
+ messagesCompressed: number;
121
+ }
122
+
123
+ export interface MessageIdState {
124
+ byIndex: Map<number, string>;
125
+ /** Reverse lookup: ref string -> message index. O(1) resolution. */
126
+ byRef: Map<string, number>;
127
+ nextRefIndex: number;
128
+ }
129
+
130
+ /**
131
+ * Context usage snapshot from Pi's ctx.getContextUsage().
132
+ * tokens and percent can be null when usage data is unavailable.
133
+ */
134
+ export interface ContextUsage {
135
+ tokens: number | null;
136
+ contextWindow: number;
137
+ percent: number | null;
138
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tool call signature utilities for deduplication.
3
+ *
4
+ * Creates deterministic signatures from tool name + parameters,
5
+ * used by the strategy runner to group duplicate calls.
6
+ */
7
+
8
+ export function createToolSignature(
9
+ toolName: string,
10
+ parameters: unknown,
11
+ ): string {
12
+ const normalized = normalizeParams(parameters);
13
+ return `${toolName}::${JSON.stringify(normalized)}`;
14
+ }
15
+
16
+ export function normalizeParams(value: unknown): unknown {
17
+ if (value === null || value === undefined) return undefined;
18
+ if (typeof value !== "object") return value;
19
+ if (Array.isArray(value)) return value.map(normalizeParams);
20
+
21
+ const obj = value as Record<string, unknown>;
22
+ const sorted: Record<string, unknown> = {};
23
+ for (const key of Object.keys(obj).sort()) {
24
+ const v = normalizeParams(obj[key]);
25
+ if (v !== undefined) {
26
+ sorted[key] = v;
27
+ }
28
+ }
29
+ return sorted;
30
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Glob matching and tool/file path protection for pruning strategies.
3
+ *
4
+ * Custom glob implementation (no external dependency) supporting:
5
+ * - `*` matches any chars except `/`
6
+ * - `**` matches any chars including `/`
7
+ * - `?` matches single char except `/`
8
+ */
9
+
10
+ export function matchesGlob(input: string, pattern: string): boolean {
11
+ return globToRegex(pattern).test(input);
12
+ }
13
+
14
+ function globToRegex(pattern: string): RegExp {
15
+ let result = "^";
16
+ let i = 0;
17
+ while (i < pattern.length) {
18
+ const c = pattern[i];
19
+ if (c === "*") {
20
+ if (pattern[i + 1] === "*") {
21
+ if (pattern[i + 2] === "/") {
22
+ result += "(?:.*/)?";
23
+ i += 3;
24
+ } else {
25
+ result += ".*";
26
+ i += 2;
27
+ }
28
+ } else {
29
+ result += "[^/]*";
30
+ i += 1;
31
+ }
32
+ } else if (c === "?") {
33
+ result += "[^/]";
34
+ i += 1;
35
+ } else if (".+^${}()|[]\\".includes(c)) {
36
+ result += "\\" + c;
37
+ i += 1;
38
+ } else {
39
+ result += c;
40
+ i += 1;
41
+ }
42
+ }
43
+ result += "$";
44
+ return new RegExp(result);
45
+ }
46
+
47
+ export function isToolNameProtected(
48
+ toolName: string,
49
+ protectedPatterns: string[],
50
+ ): boolean {
51
+ for (const pattern of protectedPatterns) {
52
+ if (pattern === toolName) return true;
53
+ if (pattern.includes("*") || pattern.includes("?")) {
54
+ if (matchesGlob(toolName, pattern)) return true;
55
+ }
56
+ }
57
+ return false;
58
+ }
59
+
60
+ export function getFilePathsFromParameters(
61
+ _toolName: string,
62
+ parameters: Record<string, unknown>,
63
+ ): string[] {
64
+ const paths: string[] = [];
65
+ if (typeof parameters.filePath === "string") {
66
+ paths.push(parameters.filePath);
67
+ }
68
+ return paths;
69
+ }
70
+
71
+ export function isFilePathProtected(
72
+ filePaths: string[],
73
+ patterns: string[],
74
+ ): boolean {
75
+ if (filePaths.length === 0 || patterns.length === 0) return false;
76
+ return filePaths.some((fp) => patterns.some((p) => matchesGlob(fp, p)));
77
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Staleness predicate for error tool results.
3
+ *
4
+ * Used by the strategy runner to identify error outputs
5
+ * old enough to be pruned from context.
6
+ */
7
+
8
+ export function isStaleError(
9
+ entry: {
10
+ status: "pending" | "running" | "completed" | "error" | undefined;
11
+ turn: number;
12
+ },
13
+ currentTurn: number,
14
+ turnThreshold: number,
15
+ ): boolean {
16
+ if (entry.status !== "error") return false;
17
+ return currentTurn - entry.turn >= turnThreshold;
18
+ }
@@ -0,0 +1,149 @@
1
+ import { BASE_PROTECTED_TOOLS, type DcpConfig } from "../config.ts";
2
+ import type { SessionState } from "../state/types.ts";
3
+ import {
4
+ isToolNameProtected,
5
+ getFilePathsFromParameters,
6
+ isFilePathProtected,
7
+ } from "./protected-patterns.ts";
8
+ import { createToolSignature } from "./deduplication.ts";
9
+ import { isStaleError } from "./purge-errors.ts";
10
+
11
+ export interface StrategyResult {
12
+ pruned: number;
13
+ tokensSaved: number;
14
+ }
15
+
16
+ /**
17
+ * Run all enabled pruning strategies against the current tool cache.
18
+ * Owns: guard checks, protected-tools resolution, eligibility filtering, stat bookkeeping.
19
+ */
20
+ export function runStrategies(
21
+ state: SessionState,
22
+ config: DcpConfig,
23
+ ): StrategyResult {
24
+ if (state.toolIdList.length === 0) {
25
+ return { pruned: 0, tokensSaved: 0 };
26
+ }
27
+ if (state.manualMode === "active" && !config.manualMode.automaticStrategies) {
28
+ return { pruned: 0, tokensSaved: 0 };
29
+ }
30
+
31
+ let pruned = 0;
32
+ let tokensSaved = 0;
33
+
34
+ // --- Deduplication ---
35
+ if (config.strategies.deduplication.enabled) {
36
+ const protectedTools = [
37
+ ...BASE_PROTECTED_TOOLS,
38
+ ...config.strategies.deduplication.protectedTools,
39
+ ];
40
+
41
+ const unpruned = state.toolIdList.filter(
42
+ (id) => !state.prune.tools.has(id),
43
+ );
44
+
45
+ // Group by signature
46
+ const groups = new Map<string, string[]>();
47
+ for (const callId of unpruned) {
48
+ const entry = state.toolParameters.get(callId);
49
+ if (!entry) continue;
50
+ if (isToolNameProtected(entry.tool, protectedTools)) continue;
51
+
52
+ const filePaths = getFilePathsFromParameters(
53
+ entry.tool,
54
+ entry.parameters as Record<string, unknown>,
55
+ );
56
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns))
57
+ continue;
58
+
59
+ const sig = createToolSignature(entry.tool, entry.parameters);
60
+ const group = groups.get(sig) ?? [];
61
+ group.push(callId);
62
+ groups.set(sig, group);
63
+ }
64
+
65
+ // Prune all but last in each group
66
+ for (const [, callIds] of groups) {
67
+ if (callIds.length <= 1) continue;
68
+ for (let i = 0; i < callIds.length - 1; i++) {
69
+ const callId = callIds[i];
70
+ const entry = state.toolParameters.get(callId);
71
+ const tokens = entry?.tokenCount ?? 0;
72
+ state.prune.tools.set(callId, tokens);
73
+ pruned++;
74
+ tokensSaved += tokens;
75
+ }
76
+ }
77
+ }
78
+
79
+ // --- Purge Errors ---
80
+ if (config.strategies.purgeErrors.enabled) {
81
+ const protectedTools = [
82
+ ...BASE_PROTECTED_TOOLS,
83
+ ...config.strategies.purgeErrors.protectedTools,
84
+ ];
85
+ const turnThreshold = config.strategies.purgeErrors.turns;
86
+ const unpruned = state.toolIdList.filter(
87
+ (id) => !state.prune.tools.has(id),
88
+ );
89
+
90
+ for (const callId of unpruned) {
91
+ const entry = state.toolParameters.get(callId);
92
+ if (!entry) continue;
93
+ if (isToolNameProtected(entry.tool, protectedTools)) continue;
94
+ if (!isStaleError(entry, state.currentTurn, turnThreshold)) continue;
95
+
96
+ const filePaths = getFilePathsFromParameters(
97
+ entry.tool,
98
+ entry.parameters as Record<string, unknown>,
99
+ );
100
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns))
101
+ continue;
102
+
103
+ const tokens = entry.tokenCount ?? 0;
104
+ state.prune.tools.set(callId, tokens);
105
+ pruned++;
106
+ tokensSaved += tokens;
107
+ }
108
+ }
109
+
110
+ // Update stats once
111
+ state.stats.totalPruneTokens += tokensSaved;
112
+ state.stats.toolsPruned += pruned;
113
+
114
+ return { pruned, tokensSaved };
115
+ }
116
+
117
+ /**
118
+ * Sweep variant: prune all non-protected completed tool outputs.
119
+ * Used by the dcp:sweep command.
120
+ */
121
+ export function sweepAll(
122
+ state: SessionState,
123
+ config: DcpConfig,
124
+ ): StrategyResult {
125
+ const protectedTools = new Set([
126
+ ...BASE_PROTECTED_TOOLS,
127
+ ...config.compress.protectedTools,
128
+ ]);
129
+
130
+ let pruned = 0;
131
+ let tokensSaved = 0;
132
+
133
+ for (const [toolCallId, entry] of state.toolParameters) {
134
+ if (state.prune.tools.has(toolCallId)) continue;
135
+ if (protectedTools.has(entry.tool)) continue;
136
+ if (entry.status !== "completed") continue;
137
+
138
+ const tokens = entry.tokenCount ?? 0;
139
+ state.prune.tools.set(toolCallId, tokens);
140
+ pruned++;
141
+ tokensSaved += tokens;
142
+ }
143
+
144
+ state.stats.toolsPruned += pruned;
145
+ state.stats.totalPruneTokens += tokensSaved;
146
+ state.stats.pruneTokenCounter += tokensSaved;
147
+
148
+ return { pruned, tokensSaved };
149
+ }
@@ -0,0 +1,85 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+
3
+ /**
4
+ * Find the index of the first text part in a content array.
5
+ * Returns -1 if no text part exists.
6
+ */
7
+ function findTextPartIndex(content: unknown[]): number {
8
+ return content.findIndex(
9
+ (p) =>
10
+ typeof p === "object" &&
11
+ p !== null &&
12
+ (p as unknown as Record<string, unknown>).type === "text",
13
+ );
14
+ }
15
+
16
+ /**
17
+ * Append text to the first text part of a message.
18
+ * Idempotent: skips if marker string is already present in the text part.
19
+ * Handles E9 string content, array content, and missing text parts.
20
+ * Returns the original message by reference if no change was made.
21
+ */
22
+ export function appendText(
23
+ msg: AgentMessage,
24
+ text: string,
25
+ marker?: string,
26
+ ): AgentMessage {
27
+ if (!("content" in msg)) return msg;
28
+
29
+ // E9: UserMessage.content can be a plain string
30
+ if (typeof msg.content === "string") {
31
+ if (marker && msg.content.includes(marker)) return msg;
32
+ return {
33
+ ...msg,
34
+ content: [{ type: "text" as const, text: `${msg.content}${text}` }],
35
+ } as AgentMessage;
36
+ }
37
+
38
+ if (!Array.isArray(msg.content)) return msg;
39
+
40
+ const idx = findTextPartIndex(msg.content);
41
+ if (idx === -1) return msg;
42
+
43
+ const textPart = msg.content[idx] as unknown as {
44
+ type: string;
45
+ text: string;
46
+ };
47
+ if (marker && textPart.text.includes(marker)) return msg;
48
+
49
+ const newContent = [...msg.content];
50
+ newContent[idx] = {
51
+ ...textPart,
52
+ text: `${textPart.text}${text}`,
53
+ } as (typeof newContent)[number];
54
+
55
+ return { ...msg, content: newContent } as AgentMessage;
56
+ }
57
+
58
+ /**
59
+ * Transform all text parts in a message via a mapping function.
60
+ * Returns the original message by reference if fn returns identical strings.
61
+ */
62
+ export function mapText(
63
+ msg: AgentMessage,
64
+ fn: (text: string) => string,
65
+ ): AgentMessage {
66
+ if (!("content" in msg)) return msg;
67
+ if (!Array.isArray(msg.content)) return msg;
68
+
69
+ let changed = false;
70
+ const newContent = msg.content.map((part) => {
71
+ if (typeof part !== "object" || part === null) return part;
72
+ const p = part as unknown as Record<string, unknown>;
73
+ if (p.type !== "text" || typeof p.text !== "string") return part;
74
+
75
+ const mapped = fn(p.text as string);
76
+ if (mapped !== p.text) {
77
+ changed = true;
78
+ return { ...part, text: mapped };
79
+ }
80
+ return part;
81
+ });
82
+
83
+ if (!changed) return msg;
84
+ return { ...msg, content: newContent } as AgentMessage;
85
+ }