decorated-pi 0.5.4 → 0.6.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.
- package/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compaction — custom compaction model + auto-resume.
|
|
3
|
+
*
|
|
4
|
+
* Uses the configured compact model (from settings.ts) to summarize messages
|
|
5
|
+
* on session_before_compact. After auto-compaction, sends a "continue" message
|
|
6
|
+
* to resume the agent loop.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { generateSummary, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
12
|
+
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { getCompactModelKey } from "../settings.js";
|
|
17
|
+
import { parseModelKey } from "../settings.js";
|
|
18
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
19
|
+
|
|
20
|
+
interface PiCompactionSettings {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
reserveTokens: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AutoCompactionCandidate {
|
|
26
|
+
messages: any[];
|
|
27
|
+
usage: { tokens: number | null; contextWindow: number } | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PI_COMPACTION_SETTINGS: PiCompactionSettings = {
|
|
31
|
+
enabled: true,
|
|
32
|
+
reserveTokens: 16_384,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function readJsonObject(filePath: string): any | undefined {
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.existsSync(filePath)) return undefined;
|
|
38
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
39
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadPiCompactionSettings(cwd: string): PiCompactionSettings {
|
|
46
|
+
const globalSettings = readJsonObject(resolve(os.homedir(), ".pi", "agent", "settings.json"));
|
|
47
|
+
const projectSettings = readJsonObject(resolve(cwd, ".pi", "settings.json"));
|
|
48
|
+
const merged = {
|
|
49
|
+
...DEFAULT_PI_COMPACTION_SETTINGS,
|
|
50
|
+
...(globalSettings?.compaction ?? {}),
|
|
51
|
+
...(projectSettings?.compaction ?? {}),
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
enabled: merged.enabled !== false,
|
|
55
|
+
reserveTokens: typeof merged.reserveTokens === "number" ? merged.reserveTokens : DEFAULT_PI_COMPACTION_SETTINGS.reserveTokens,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getLastAssistantMessage(messages: any[]): any | undefined {
|
|
60
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
61
|
+
if (messages[i]?.role === "assistant") return messages[i];
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shouldExpectAutoCompaction(
|
|
67
|
+
messages: any[],
|
|
68
|
+
usage: { tokens: number | null; contextWindow: number } | undefined,
|
|
69
|
+
settings: PiCompactionSettings,
|
|
70
|
+
): boolean {
|
|
71
|
+
if (!settings.enabled) return false;
|
|
72
|
+
const lastAssistant = getLastAssistantMessage(messages);
|
|
73
|
+
if (!lastAssistant) return false;
|
|
74
|
+
const contextWindow = usage?.contextWindow ?? 0;
|
|
75
|
+
if (contextWindow > 0 && isContextOverflow(lastAssistant, contextWindow)) return true;
|
|
76
|
+
if (!usage || usage.tokens === null) return false;
|
|
77
|
+
return usage.tokens > usage.contextWindow - settings.reserveTokens;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shouldAutoResumeCompaction(
|
|
81
|
+
prePromptCompactionPending: boolean,
|
|
82
|
+
postAgentEndCandidate: AutoCompactionCandidate | null,
|
|
83
|
+
settings: PiCompactionSettings,
|
|
84
|
+
customInstructions?: string,
|
|
85
|
+
): boolean {
|
|
86
|
+
if (customInstructions !== undefined) return false;
|
|
87
|
+
if (prePromptCompactionPending) return true;
|
|
88
|
+
if (!postAgentEndCandidate) return false;
|
|
89
|
+
return shouldExpectAutoCompaction(postAgentEndCandidate.messages, postAgentEndCandidate.usage, settings);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getConfiguredCompactModel(registry: any): Model<any> | null {
|
|
93
|
+
const key = getCompactModelKey();
|
|
94
|
+
if (!key) return null;
|
|
95
|
+
const parsed = parseModelKey(key);
|
|
96
|
+
if (!parsed) return null;
|
|
97
|
+
return registry.find(parsed.provider, parsed.modelId) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const TURN_PREFIX_PROMPT = `Summarize this turn prefix to provide context for the retained suffix. Be concise. Focus on what's needed to understand the kept suffix.`;
|
|
101
|
+
|
|
102
|
+
async function generateTurnPrefixSummary(
|
|
103
|
+
messages: any[], model: Model<any>, reserveTokens: number,
|
|
104
|
+
apiKey: string, headers: Record<string, string> | undefined, signal: AbortSignal,
|
|
105
|
+
): Promise<string> {
|
|
106
|
+
const { complete } = await import("@earendil-works/pi-ai");
|
|
107
|
+
const ct = serializeConversation(convertToLlm(messages));
|
|
108
|
+
const resp = await complete(model, {
|
|
109
|
+
systemPrompt: "You are a context summarization assistant. Produce a structured summary only.",
|
|
110
|
+
messages: [{ role: "user" as const, content: [{ type: "text" as const, text: `<conversation>\n${ct}\n</conversation>\n\n${TURN_PREFIX_PROMPT}` }], timestamp: Date.now() }],
|
|
111
|
+
}, { maxTokens: Math.floor(0.5 * reserveTokens), signal, apiKey, headers });
|
|
112
|
+
if (resp.stopReason === "error") throw new Error(resp.errorMessage ?? "Turn prefix summarization failed");
|
|
113
|
+
return resp.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let prePromptCompactionPending = false;
|
|
117
|
+
let postAgentEndCandidate: AutoCompactionCandidate | null = null;
|
|
118
|
+
let currentCompactionIsAuto = false;
|
|
119
|
+
|
|
120
|
+
export const compactionModule: Module = {
|
|
121
|
+
name: "compaction",
|
|
122
|
+
hooks: {
|
|
123
|
+
input: [() => { prePromptCompactionPending = true; postAgentEndCandidate = null; }],
|
|
124
|
+
before_agent_start: [() => { prePromptCompactionPending = false; postAgentEndCandidate = null; }],
|
|
125
|
+
agent_start: [() => { prePromptCompactionPending = false; postAgentEndCandidate = null; }],
|
|
126
|
+
agent_end: [
|
|
127
|
+
(event, ctx) => {
|
|
128
|
+
prePromptCompactionPending = false;
|
|
129
|
+
postAgentEndCandidate = { messages: event.messages, usage: ctx.getContextUsage() };
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
session_before_compact: [
|
|
133
|
+
async (event, ctx) => {
|
|
134
|
+
const compactionSettings = loadPiCompactionSettings(ctx.cwd);
|
|
135
|
+
const { isContextOverflow } = await import("@earendil-works/pi-ai");
|
|
136
|
+
const isAuto = shouldExpectAutoCompaction(
|
|
137
|
+
postAgentEndCandidate?.messages ?? [],
|
|
138
|
+
postAgentEndCandidate?.usage,
|
|
139
|
+
compactionSettings,
|
|
140
|
+
);
|
|
141
|
+
// For simplicity, treat session_before_compact as auto if recent agent_end was likely-overflow
|
|
142
|
+
// and no custom instructions given.
|
|
143
|
+
const isAutoResume = isAuto && !event.customInstructions;
|
|
144
|
+
currentCompactionIsAuto = isAutoResume;
|
|
145
|
+
prePromptCompactionPending = false;
|
|
146
|
+
postAgentEndCandidate = null;
|
|
147
|
+
|
|
148
|
+
const model = getConfiguredCompactModel(ctx.modelRegistry);
|
|
149
|
+
if (!model) return;
|
|
150
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
151
|
+
if (!auth.ok) {
|
|
152
|
+
if (ctx.hasUI) ctx.ui.notify(`Compact model auth failed: ${auth.error}`, "warning");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const { preparation, customInstructions, signal } = event;
|
|
156
|
+
const { messagesToSummarize, turnPrefixMessages, isSplitTurn, tokensBefore, firstKeptEntryId, previousSummary, settings } = preparation;
|
|
157
|
+
if (ctx.hasUI) ctx.ui.notify(`🗜️ Compacting with ${model.id} (${tokensBefore.toLocaleString()} tokens)...`, "info");
|
|
158
|
+
try {
|
|
159
|
+
let summary: string;
|
|
160
|
+
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
|
161
|
+
const [hs, ps] = await Promise.all([
|
|
162
|
+
messagesToSummarize.length > 0
|
|
163
|
+
? generateSummary(messagesToSummarize, model, settings.reserveTokens, auth.apiKey ?? "", auth.headers, signal, customInstructions, previousSummary)
|
|
164
|
+
: Promise.resolve("No prior history."),
|
|
165
|
+
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, auth.apiKey ?? "", auth.headers, signal),
|
|
166
|
+
]);
|
|
167
|
+
summary = `${hs}\n\n---\n\n**Turn Context (split turn):**\n\n${ps}`;
|
|
168
|
+
} else {
|
|
169
|
+
summary = await generateSummary(messagesToSummarize, model, settings.reserveTokens, auth.apiKey ?? "", auth.headers, signal, customInstructions, previousSummary);
|
|
170
|
+
}
|
|
171
|
+
// session_before_compact is a parallel event — handlers may not
|
|
172
|
+
// return a value (skeleton discards compose returns for
|
|
173
|
+
// non-compose events). The summary is intentionally lost; the
|
|
174
|
+
// session_compact handler below only reads the
|
|
175
|
+
// currentCompactionIsAuto flag and does not consume the summary.
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (signal.aborted) return;
|
|
178
|
+
if (ctx.hasUI) ctx.ui.notify(`Compact failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
session_compact: [
|
|
183
|
+
(_event, _ctx, pi) => {
|
|
184
|
+
const shouldResume = currentCompactionIsAuto;
|
|
185
|
+
currentCompactionIsAuto = false;
|
|
186
|
+
if (!shouldResume) return;
|
|
187
|
+
pi.sendMessage({
|
|
188
|
+
customType: "auto_compact_resume",
|
|
189
|
+
content: "The context was just auto-compacted. Continue the current task based on the summary above. Do not repeat completed work. If unsure about progress, briefly summarize current state then continue.",
|
|
190
|
+
display: false,
|
|
191
|
+
}, { triggerTurn: true });
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export function setupCompaction(sk: Skeleton): void {
|
|
198
|
+
sk.register(compactionModule);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const __modelIntegrationTest = {
|
|
202
|
+
shouldExpectAutoCompaction,
|
|
203
|
+
shouldAutoResumeCompaction,
|
|
204
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* externalize — large tool_result → temp file.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the messages segment small so the prompt cache stays warm across turns.
|
|
5
|
+
* Applies to ANY tool whose first text content exceeds OUTPUT_EXTERNALIZE_THRESHOLD
|
|
6
|
+
* bytes — read, bash, MCP tools, all the same. The tool name is preserved
|
|
7
|
+
* in the temp filename so users can correlate the truncation with the
|
|
8
|
+
* call that produced it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import type { Module } from "./skeleton.js";
|
|
16
|
+
|
|
17
|
+
export const TOOL_OUTPUT_TEMP_DIR = path.join(os.tmpdir(), "decorated-pi-results");
|
|
18
|
+
export const OUTPUT_EXTERNALIZE_THRESHOLD = 30_000;
|
|
19
|
+
|
|
20
|
+
/** Write content to a temp file under TOOL_OUTPUT_TEMP_DIR.
|
|
21
|
+
* Returns the file path, or undefined on failure (e.g., /tmp full).
|
|
22
|
+
* Exported so other modules (e.g. tools/mcp/externalize.ts) can
|
|
23
|
+
* write to the same location. */
|
|
24
|
+
export function writeOutputToTemp(
|
|
25
|
+
toolName: string,
|
|
26
|
+
toolCallId: string,
|
|
27
|
+
content: string,
|
|
28
|
+
): string | undefined {
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(TOOL_OUTPUT_TEMP_DIR)) fs.mkdirSync(TOOL_OUTPUT_TEMP_DIR, { recursive: true });
|
|
31
|
+
const id = toolCallId ? toolCallId.slice(0, 12) : randomBytes(8).toString("hex");
|
|
32
|
+
const filePath = path.join(TOOL_OUTPUT_TEMP_DIR, `${toolName}-${id}.txt`);
|
|
33
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
34
|
+
return filePath;
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Externalize a tool_result event if content is above the threshold.
|
|
41
|
+
* Returns the modified event, or undefined to leave the original untouched. */
|
|
42
|
+
export function maybeExternalizeToolResult(event: any): any | undefined {
|
|
43
|
+
if (!Array.isArray(event.content) || event.content.length === 0) return undefined;
|
|
44
|
+
const first = event.content[0];
|
|
45
|
+
if (!first || first.type !== "text" || typeof first.text !== "string") return undefined;
|
|
46
|
+
const text = first.text;
|
|
47
|
+
if (text.length <= OUTPUT_EXTERNALIZE_THRESHOLD) return undefined;
|
|
48
|
+
|
|
49
|
+
const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
|
|
50
|
+
if (!filePath) return undefined;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...event,
|
|
54
|
+
content: [{
|
|
55
|
+
type: "text" as const,
|
|
56
|
+
text: `[Output truncated: ${text.length.toLocaleString()} chars. Full output: ${filePath}]`,
|
|
57
|
+
}],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const externalizeModule: Module = {
|
|
62
|
+
name: "externalize",
|
|
63
|
+
hooks: {
|
|
64
|
+
tool_result: [
|
|
65
|
+
(event) => {
|
|
66
|
+
return maybeExternalizeToolResult(event);
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image-vision — when read is called on an image file, replace the result with
|
|
3
|
+
* a vision model analysis instead of returning raw bytes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fileTypeFromFile } from "file-type";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import { extname, resolve } from "node:path";
|
|
9
|
+
import OpenAI from "openai";
|
|
10
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
+
import { getImageModelKey, parseModelKey } from "../settings.js";
|
|
12
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
13
|
+
|
|
14
|
+
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
15
|
+
|
|
16
|
+
async function detectImageMimeType(filePath: string): Promise<string | null> {
|
|
17
|
+
try {
|
|
18
|
+
const type = await fileTypeFromFile(filePath);
|
|
19
|
+
if (!type || !SUPPORTED_IMAGE_TYPES.has(type.mime)) return null;
|
|
20
|
+
return type.mime;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PROMPT = "Please describe this image in detail, including any text, diagrams, UI elements, or code visible in it.";
|
|
27
|
+
|
|
28
|
+
export async function analyzeImage(
|
|
29
|
+
model: Model<any>, imageBase64: string, mediaType: string,
|
|
30
|
+
apiKey: string, extraHeaders: Record<string, string>,
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
if (model.api === "anthropic-messages") {
|
|
33
|
+
return analyzeAnthropic(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
34
|
+
}
|
|
35
|
+
return analyzeOpenAI(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function analyzeOpenAI(
|
|
39
|
+
model: Model<any>, imageBase64: string, mediaType: string,
|
|
40
|
+
apiKey: string, extraHeaders: Record<string, string>,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const client = new OpenAI({ apiKey, baseURL: model.baseUrl, defaultHeaders: extraHeaders });
|
|
43
|
+
const resp = await client.chat.completions.create({
|
|
44
|
+
model: model.id,
|
|
45
|
+
messages: [{ role: "user", content: [
|
|
46
|
+
{ type: "text", text: DEFAULT_PROMPT },
|
|
47
|
+
{ type: "image_url", image_url: { url: `data:${mediaType};base64,${imageBase64}` } },
|
|
48
|
+
]}],
|
|
49
|
+
max_completion_tokens: 4096,
|
|
50
|
+
}, { signal: AbortSignal.timeout(60_000) });
|
|
51
|
+
return resp.choices[0]?.message?.content ?? "No analysis returned.";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function analyzeAnthropic(
|
|
55
|
+
model: Model<any>, imageBase64: string, mediaType: string,
|
|
56
|
+
apiKey: string, extraHeaders: Record<string, string>,
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const ep = `${model.baseUrl.replace(/\/+$/, "")}/messages`;
|
|
59
|
+
const resp = await fetch(ep, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01", ...extraHeaders },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
model: model.id, max_tokens: 4096,
|
|
64
|
+
messages: [{ role: "user", content: [
|
|
65
|
+
{ type: "text", text: DEFAULT_PROMPT },
|
|
66
|
+
{ type: "image", source: { type: "base64", media_type: mediaType, data: imageBase64 } },
|
|
67
|
+
]}],
|
|
68
|
+
}),
|
|
69
|
+
signal: AbortSignal.timeout(60_000),
|
|
70
|
+
});
|
|
71
|
+
if (!resp.ok) throw new Error(`Vision API error ${resp.status}: ${(await resp.text()).slice(0, 300)}`);
|
|
72
|
+
const data = (await resp.json()) as any;
|
|
73
|
+
return data.content?.[0]?.text ?? "No analysis returned.";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pendingImageFallbacks = new Set<string>();
|
|
77
|
+
|
|
78
|
+
export const imageVisionModule: Module = {
|
|
79
|
+
name: "image-vision",
|
|
80
|
+
hooks: {
|
|
81
|
+
tool_call: [
|
|
82
|
+
async (event, ctx) => {
|
|
83
|
+
if (event.toolName !== "read") return;
|
|
84
|
+
const filePath: string | undefined = (event.input as any)?.file ?? (event.input as any)?.path;
|
|
85
|
+
if (!filePath) return;
|
|
86
|
+
const mimeType = await detectImageMimeType(resolve(ctx.cwd, filePath));
|
|
87
|
+
if (!mimeType) return;
|
|
88
|
+
if (!getImageModelKey()) return;
|
|
89
|
+
pendingImageFallbacks.add(event.toolCallId);
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
tool_result: [
|
|
93
|
+
async (event, ctx) => {
|
|
94
|
+
if (!pendingImageFallbacks.delete(event.toolCallId)) return;
|
|
95
|
+
const filePath: string | undefined = (event.input as any)?.file ?? (event.input as any)?.path;
|
|
96
|
+
if (!filePath) return;
|
|
97
|
+
const imageKey = getImageModelKey();
|
|
98
|
+
if (!imageKey) return;
|
|
99
|
+
const parsed = parseModelKey(imageKey);
|
|
100
|
+
if (!parsed) return;
|
|
101
|
+
const imageModel = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
|
|
102
|
+
if (!imageModel) return;
|
|
103
|
+
try {
|
|
104
|
+
const absPath = resolve(ctx.cwd, filePath);
|
|
105
|
+
const imageData = fs.readFileSync(absPath);
|
|
106
|
+
const imageBase64 = imageData.toString("base64");
|
|
107
|
+
const mimeType = (await detectImageMimeType(absPath)) ?? "image/png";
|
|
108
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(imageModel as Model<any>);
|
|
109
|
+
if (!auth.ok) return;
|
|
110
|
+
const analysis = await analyzeImage(
|
|
111
|
+
imageModel as Model<any>, imageBase64, mimeType,
|
|
112
|
+
auth.apiKey ?? "", auth.headers ?? {},
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
...event,
|
|
116
|
+
content: [{ type: "text", text: `[Image analysis via ${parsed.provider}/${parsed.modelId}]\n\n${analysis}` }],
|
|
117
|
+
details: { imageModel: imageKey, originalPath: filePath },
|
|
118
|
+
};
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
...event,
|
|
122
|
+
content: [{ type: "text", text: `Image analysis failed: ${error instanceof Error ? error.message : error}` }],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export function setupImageVision(sk: Skeleton): void {
|
|
131
|
+
sk.register(imageVisionModule);
|
|
132
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inject-agents-md — when read/edit is called, scan parent dirs for AGENTS.md/CLAUDE.md
|
|
3
|
+
* and inject their content into the tool result so the LLM sees the relevant context.
|
|
4
|
+
*
|
|
5
|
+
* State is persisted to session via pi.appendEntry so it survives resume/reload.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { dirname, resolve, relative, join, normalize } from "node:path";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import type { Module } from "./skeleton.js";
|
|
11
|
+
|
|
12
|
+
const CUSTOM_TYPE = "decorated-pi.subdir-agents";
|
|
13
|
+
const AGENTS_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
|
14
|
+
|
|
15
|
+
interface SessionLikeEntry {
|
|
16
|
+
type: string;
|
|
17
|
+
customType?: string;
|
|
18
|
+
data?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const discovered = new Set<string>();
|
|
22
|
+
const pendingPaths = new Map<string, string>();
|
|
23
|
+
let lastCwd = "";
|
|
24
|
+
|
|
25
|
+
function normalizeAbsPath(cwd: string, p: string): string {
|
|
26
|
+
return normalize(resolve(cwd, p));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function lastCompactionIndex(entries: SessionLikeEntry[]): number {
|
|
30
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
31
|
+
if (entries[i]?.type === "compaction") return i;
|
|
32
|
+
}
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function restoreFromBranch(ctx: {
|
|
37
|
+
cwd: string;
|
|
38
|
+
sessionManager: { getBranch: () => Array<SessionLikeEntry> };
|
|
39
|
+
}) {
|
|
40
|
+
discovered.clear();
|
|
41
|
+
const branch = ctx.sessionManager.getBranch();
|
|
42
|
+
const start = lastCompactionIndex(branch) + 1;
|
|
43
|
+
for (const entry of branch.slice(start)) {
|
|
44
|
+
if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE)
|
|
45
|
+
continue;
|
|
46
|
+
const paths = entry.data as string[] | undefined;
|
|
47
|
+
if (!Array.isArray(paths)) continue;
|
|
48
|
+
for (const p of paths) {
|
|
49
|
+
if (typeof p === "string" && p.trim()) {
|
|
50
|
+
discovered.add(normalizeAbsPath(ctx.cwd, p));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findNewAgents(
|
|
57
|
+
filePath: string,
|
|
58
|
+
cwd: string,
|
|
59
|
+
): Array<{ path: string; content: string }> {
|
|
60
|
+
const resolvedCwd = resolve(cwd);
|
|
61
|
+
let dir = dirname(resolve(cwd, filePath));
|
|
62
|
+
const results: Array<{ path: string; content: string }> = [];
|
|
63
|
+
|
|
64
|
+
while (true) {
|
|
65
|
+
const rel = relative(resolvedCwd, dir);
|
|
66
|
+
if (rel === "" || rel.startsWith("..")) break;
|
|
67
|
+
|
|
68
|
+
for (const name of AGENTS_NAMES) {
|
|
69
|
+
const agentsPath = normalize(join(dir, name));
|
|
70
|
+
if (existsSync(agentsPath) && !discovered.has(agentsPath)) {
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(agentsPath, "utf-8");
|
|
73
|
+
discovered.add(agentsPath);
|
|
74
|
+
results.push({ path: relative(cwd, agentsPath), content });
|
|
75
|
+
} catch {
|
|
76
|
+
/* ignore */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parent = dirname(dir);
|
|
82
|
+
if (parent === dir) break;
|
|
83
|
+
dir = parent;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return results.reverse();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const __subdirAgentsTest = { restoreFromBranch, findNewAgents };
|
|
90
|
+
|
|
91
|
+
export const injectAgentsMdModule: Module = {
|
|
92
|
+
name: "inject-agents-md",
|
|
93
|
+
hooks: {
|
|
94
|
+
session_start: [
|
|
95
|
+
(_event, ctx) => {
|
|
96
|
+
lastCwd = ctx.cwd;
|
|
97
|
+
restoreFromBranch({
|
|
98
|
+
cwd: ctx.cwd,
|
|
99
|
+
sessionManager: ctx.sessionManager as any,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
session_compact: [
|
|
104
|
+
() => {
|
|
105
|
+
discovered.clear();
|
|
106
|
+
pendingPaths.clear();
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
tool_call: [
|
|
110
|
+
(event) => {
|
|
111
|
+
if (event.toolName !== "read" && event.toolName !== "edit")
|
|
112
|
+
return;
|
|
113
|
+
const path = (event.input as { path?: string })?.path;
|
|
114
|
+
if (path) pendingPaths.set(event.toolCallId, path);
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
tool_result: [
|
|
118
|
+
(event, ctx, pi) => {
|
|
119
|
+
const path = pendingPaths.get(event.toolCallId);
|
|
120
|
+
pendingPaths.delete(event.toolCallId);
|
|
121
|
+
if (!path || !event.content || !Array.isArray(event.content))
|
|
122
|
+
return;
|
|
123
|
+
const cwd = ctx.cwd ?? lastCwd;
|
|
124
|
+
const agents = findNewAgents(path, cwd);
|
|
125
|
+
if (agents.length === 0) return;
|
|
126
|
+
|
|
127
|
+
const injections = agents
|
|
128
|
+
.map((a) => `[Directory Context: ${a.path}]\n${a.content}`)
|
|
129
|
+
.join("\n\n---\n\n");
|
|
130
|
+
const names = agents.map((a) => a.path).join(", ");
|
|
131
|
+
const label =
|
|
132
|
+
agents.length === 1 ? "AGENTS.md" : "AGENTS.md files";
|
|
133
|
+
if (ctx.hasUI)
|
|
134
|
+
ctx.ui.notify(`📋 Loaded ${label}: ${names}`, "info");
|
|
135
|
+
|
|
136
|
+
const relativePaths = agents
|
|
137
|
+
.map((a) => resolve(cwd, a.path))
|
|
138
|
+
.map((p) => relative(cwd, p));
|
|
139
|
+
pi.appendEntry(CUSTOM_TYPE, relativePaths);
|
|
140
|
+
|
|
141
|
+
const newContent = [...event.content];
|
|
142
|
+
newContent.push({ type: "text", text: `\n\n${injections}` });
|
|
143
|
+
return { ...event, content: newContent };
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
session_shutdown: [
|
|
147
|
+
() => {
|
|
148
|
+
discovered.clear();
|
|
149
|
+
pendingPaths.clear();
|
|
150
|
+
lastCwd = "";
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* System-prompt guidance for the inject-agents-md hook — tells the
|
|
158
|
+
* LLM not to waste tool calls re-reading AGENTS.md / CLAUDE.md, since
|
|
159
|
+
* this hook already auto-injects them.
|
|
160
|
+
*/
|
|
161
|
+
export const INJECT_AGENTS_MD_GUIDANCE = [
|
|
162
|
+
"### Context Loading, AGENTS.md / CLAUDE.md are auto-injected",
|
|
163
|
+
"- DO NOT read any **AGENTS.md** or **CLAUDE.md** files unless you're explicitly asked to, these files will loaded automatically if necessary.",
|
|
164
|
+
].join("\n");
|