pi-chalin 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,294 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { resolveChalinPaths, type ChalinPathsOptions } from "./paths.ts";
5
+
6
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp?tools=web_search_exa,web_fetch_exa";
7
+ const SEARCH_TTL_MS = 6 * 60 * 60 * 1000;
8
+ const FETCH_TTL_MS = 24 * 60 * 60 * 1000;
9
+
10
+ export type WebFetchFreshness = "cache-ok" | "prefer-fresh" | "must-be-fresh";
11
+ export type WebFetchDepth = "snippets" | "content";
12
+
13
+ export interface WebSourceEvidence {
14
+ title: string;
15
+ url: string;
16
+ content: string;
17
+ }
18
+
19
+ export interface WebContextBundle {
20
+ query?: string;
21
+ urls?: string[];
22
+ provider: "exa-mcp";
23
+ observedAt: string;
24
+ cache: { hit: boolean; key: string; ttlMs: number };
25
+ sources: WebSourceEvidence[];
26
+ summary: string;
27
+ warnings: string[];
28
+ }
29
+
30
+ export type WebFetchAuditFreshness = "fresh" | "stale" | "no-ttl";
31
+
32
+ export interface WebFetchAuditEntry {
33
+ key: string;
34
+ kind: "search" | "fetch" | "unknown";
35
+ label: string;
36
+ provider: WebContextBundle["provider"];
37
+ observedAt: string;
38
+ ageMs: number;
39
+ ttlMs: number;
40
+ freshness: WebFetchAuditFreshness;
41
+ sourceCount: number;
42
+ warnings: string[];
43
+ sources: Array<Pick<WebSourceEvidence, "title" | "url">>;
44
+ }
45
+
46
+ export interface WebFetchAuditOptions extends ChalinPathsOptions {
47
+ now?: number;
48
+ }
49
+
50
+ export interface WebSearchRequest extends ChalinPathsOptions {
51
+ query: string;
52
+ maxSources?: number;
53
+ depth?: WebFetchDepth;
54
+ freshness?: WebFetchFreshness;
55
+ signal?: AbortSignal;
56
+ }
57
+
58
+ export interface WebFetchUrlRequest extends ChalinPathsOptions {
59
+ urls: string[];
60
+ freshness?: WebFetchFreshness;
61
+ signal?: AbortSignal;
62
+ }
63
+
64
+ interface ExaMcpRpcResponse {
65
+ result?: { content?: Array<{ type?: string; text?: string }>; isError?: boolean };
66
+ error?: { code?: number; message?: string };
67
+ }
68
+
69
+ export async function searchWeb(request: WebSearchRequest): Promise<WebContextBundle> {
70
+ const normalizedQuery = request.query.trim();
71
+ if (!normalizedQuery) throw new Error("chalin_web_search requires a non-empty query.");
72
+ const maxSources = clampInteger(request.maxSources, 1, 10, 5);
73
+ const depth = request.depth ?? "snippets";
74
+ const freshness = request.freshness ?? "cache-ok";
75
+ const key = cacheKey("search", { query: normalizedQuery, maxSources, depth });
76
+ const ttlMs = freshness === "must-be-fresh" ? 0 : SEARCH_TTL_MS;
77
+ const cached = freshness !== "must-be-fresh" ? readCache(request, key, ttlMs) : undefined;
78
+ if (cached && freshness === "cache-ok") return { ...cached, cache: { ...cached.cache, hit: true } };
79
+
80
+ const text = await callExaMcp("web_search_exa", {
81
+ query: normalizedQuery,
82
+ numResults: maxSources,
83
+ livecrawl: freshness === "must-be-fresh" ? "always" : "fallback",
84
+ type: "auto",
85
+ contextMaxCharacters: depth === "content" ? 20_000 : 3_000,
86
+ }, request.signal);
87
+ const sources = parseExaTextResults(text).slice(0, maxSources);
88
+ const bundle = toBundle({ query: normalizedQuery, key, ttlMs, sources, warnings: sources.length === 0 ? ["Exa returned no parseable sources."] : [] });
89
+ writeCache(request, key, bundle);
90
+ return bundle;
91
+ }
92
+
93
+ export async function fetchWebUrls(request: WebFetchUrlRequest): Promise<WebContextBundle> {
94
+ const urls = request.urls.map((url) => url.trim()).filter(Boolean).slice(0, 5);
95
+ if (urls.length === 0) throw new Error("chalin_web_search fetch mode requires at least one URL.");
96
+ const freshness = request.freshness ?? "cache-ok";
97
+ const key = cacheKey("fetch", { urls });
98
+ const ttlMs = freshness === "must-be-fresh" ? 0 : FETCH_TTL_MS;
99
+ const cached = freshness !== "must-be-fresh" ? readCache(request, key, ttlMs) : undefined;
100
+ if (cached && freshness === "cache-ok") return { ...cached, cache: { ...cached.cache, hit: true } };
101
+
102
+ const text = await callExaMcp("web_fetch_exa", { urls }, request.signal);
103
+ const sources = parseExaTextResults(text);
104
+ const fallbackSources = sources.length > 0 ? sources : [{ title: urls[0] ?? "Web page", url: urls[0] ?? "", content: text.trim() }];
105
+ const bundle = toBundle({ urls, key, ttlMs, sources: fallbackSources, warnings: [] });
106
+ writeCache(request, key, bundle);
107
+ return bundle;
108
+ }
109
+
110
+ export async function callExaMcp(toolName: "web_search_exa" | "web_fetch_exa", args: Record<string, unknown>, signal?: AbortSignal): Promise<string> {
111
+ const headers: Record<string, string> = {
112
+ "Content-Type": "application/json",
113
+ "Accept": "application/json, text/event-stream",
114
+ };
115
+ if (process.env.EXA_API_KEY) headers["x-api-key"] = process.env.EXA_API_KEY;
116
+ const response = await fetch(EXA_MCP_URL, {
117
+ method: "POST",
118
+ headers,
119
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } }),
120
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(45_000)]) : AbortSignal.timeout(45_000),
121
+ });
122
+ if (!response.ok) throw new Error(`Exa MCP error ${response.status}: ${(await response.text()).slice(0, 300)}`);
123
+ const body = await response.text();
124
+ const parsed = parseMcpBody(body);
125
+ if (parsed.error) throw new Error(`Exa MCP error${parsed.error.code ? ` ${parsed.error.code}` : ""}: ${parsed.error.message ?? "Unknown error"}`);
126
+ const text = parsed.result?.content?.find((item) => item.type === "text" && item.text?.trim())?.text?.trim();
127
+ if (parsed.result?.isError) throw new Error(text || "Exa MCP returned an error.");
128
+ if (!text) throw new Error("Exa MCP returned empty content.");
129
+ return text;
130
+ }
131
+
132
+ export function formatWebBundle(bundle: WebContextBundle): string {
133
+ const lines = [
134
+ bundle.query ? `chalin web search · ${bundle.query}` : `chalin web fetch · ${bundle.urls?.join(", ")}`,
135
+ `provider: ${bundle.provider} · cache: ${bundle.cache.hit ? "hit" : "miss"} · sources: ${bundle.sources.length}`,
136
+ bundle.warnings.length > 0 ? `warnings: ${bundle.warnings.join("; ")}` : undefined,
137
+ "",
138
+ "Summary:",
139
+ bundle.summary || "No summary available.",
140
+ "",
141
+ "Sources:",
142
+ ...bundle.sources.map((source, index) => `${index + 1}. ${source.title || source.url}\n ${source.url}\n ${truncate(source.content, 420)}`),
143
+ ];
144
+ return lines.filter((line): line is string => line !== undefined).join("\n");
145
+ }
146
+
147
+ export async function listWebFetchAudit(options: WebFetchAuditOptions): Promise<WebFetchAuditEntry[]> {
148
+ const dir = path.join(resolveChalinPaths(options).projectRoot, ".pi-chalin", "cache", "webfetch");
149
+ if (!fs.existsSync(dir)) return [];
150
+ const now = options.now ?? Date.now();
151
+ const entries: WebFetchAuditEntry[] = [];
152
+ for (const fileName of fs.readdirSync(dir).filter((file) => file.endsWith(".json"))) {
153
+ const file = path.join(dir, fileName);
154
+ try {
155
+ const bundle = JSON.parse(fs.readFileSync(file, "utf-8")) as WebContextBundle;
156
+ if (!bundle || bundle.provider !== "exa-mcp" || !bundle.cache?.key) continue;
157
+ const observedMs = Date.parse(bundle.observedAt);
158
+ const ageMs = Number.isFinite(observedMs) ? Math.max(0, now - observedMs) : 0;
159
+ const ttlMs = Number(bundle.cache.ttlMs ?? 0);
160
+ entries.push({
161
+ key: bundle.cache.key,
162
+ kind: bundle.cache.key.startsWith("search-") ? "search" : bundle.cache.key.startsWith("fetch-") ? "fetch" : "unknown",
163
+ label: bundle.query ?? bundle.urls?.join(", ") ?? bundle.cache.key,
164
+ provider: bundle.provider,
165
+ observedAt: bundle.observedAt,
166
+ ageMs,
167
+ ttlMs,
168
+ freshness: ttlMs <= 0 ? "no-ttl" : ageMs <= ttlMs ? "fresh" : "stale",
169
+ sourceCount: bundle.sources.length,
170
+ warnings: bundle.warnings,
171
+ sources: bundle.sources.slice(0, 5).map((source) => ({ title: source.title, url: source.url })),
172
+ });
173
+ } catch {
174
+ // Ignore corrupt cache entries; WebFetch should never fail because diagnostics are dirty.
175
+ }
176
+ }
177
+ return entries.sort((a, b) => Date.parse(b.observedAt) - Date.parse(a.observedAt));
178
+ }
179
+
180
+ export function formatWebFetchAudit(entries: WebFetchAuditEntry[]): string {
181
+ if (entries.length === 0) return "pi-chalin WebFetch Audit\nNo cached WebFetch bundles yet.";
182
+ const lines = [
183
+ "pi-chalin WebFetch Audit",
184
+ `cached bundles: ${entries.length}`,
185
+ "",
186
+ ...entries.flatMap((entry) => [
187
+ `${freshnessIcon(entry.freshness)} ${entry.kind} · ${entry.label} · ${entry.sourceCount} source${entry.sourceCount === 1 ? "" : "s"} · ${formatAge(entry.ageMs)} old · ${entry.freshness}`,
188
+ ` provider: ${entry.provider} · key: ${entry.key}`,
189
+ entry.warnings.length ? ` warnings: ${entry.warnings.join("; ")}` : undefined,
190
+ ...entry.sources.slice(0, 3).map((source, index) => ` ${index + 1}. ${source.title || source.url} — ${source.url}`),
191
+ ].filter((line): line is string => Boolean(line))),
192
+ ];
193
+ return lines.join("\n");
194
+ }
195
+
196
+ export function parseExaTextResults(text: string): WebSourceEvidence[] {
197
+ const blocks = text.split(/(?=^Title: )/m).filter((block) => block.trim().length > 0);
198
+ const parsed = blocks.map((block, index) => {
199
+ const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim() ?? `Source ${index + 1}`;
200
+ const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim() ?? "";
201
+ let content = "";
202
+ const textStart = block.indexOf("\nText: ");
203
+ if (textStart >= 0) content = block.slice(textStart + 7).trim();
204
+ else {
205
+ const highlights = block.match(/\nHighlights:\s*\n/);
206
+ if (highlights?.index !== undefined) content = block.slice(highlights.index + highlights[0].length).trim();
207
+ }
208
+ return { title, url, content: normalizeContent(content.replace(/\n---\s*$/, "")) };
209
+ }).filter((source) => source.url && source.content);
210
+ if (parsed.length > 0) return parsed;
211
+ return [];
212
+ }
213
+
214
+ function parseMcpBody(body: string): ExaMcpRpcResponse {
215
+ for (const line of body.split("\n")) {
216
+ if (!line.startsWith("data:")) continue;
217
+ const payload = line.slice(5).trim();
218
+ if (!payload) continue;
219
+ try {
220
+ const candidate = JSON.parse(payload) as ExaMcpRpcResponse;
221
+ if (candidate.result || candidate.error) return candidate;
222
+ } catch {}
223
+ }
224
+ try {
225
+ return JSON.parse(body) as ExaMcpRpcResponse;
226
+ } catch {
227
+ throw new Error("Exa MCP returned an unparseable response.");
228
+ }
229
+ }
230
+
231
+ function toBundle(input: { query?: string; urls?: string[]; key: string; ttlMs: number; sources: WebSourceEvidence[]; warnings: string[] }): WebContextBundle {
232
+ return {
233
+ query: input.query,
234
+ urls: input.urls,
235
+ provider: "exa-mcp",
236
+ observedAt: new Date().toISOString(),
237
+ cache: { hit: false, key: input.key, ttlMs: input.ttlMs },
238
+ sources: input.sources,
239
+ summary: input.sources.map((source) => `- ${source.title}: ${truncate(source.content, 220)}`).join("\n"),
240
+ warnings: input.warnings,
241
+ };
242
+ }
243
+
244
+ function readCache(options: ChalinPathsOptions, key: string, ttlMs: number): WebContextBundle | undefined {
245
+ const file = cachePath(options, key);
246
+ if (!fs.existsSync(file)) return undefined;
247
+ try {
248
+ const bundle = JSON.parse(fs.readFileSync(file, "utf-8")) as WebContextBundle;
249
+ if (ttlMs > 0 && Date.now() - Date.parse(bundle.observedAt) > ttlMs) return undefined;
250
+ return bundle;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ function writeCache(options: ChalinPathsOptions, key: string, bundle: WebContextBundle): void {
257
+ const file = cachePath(options, key);
258
+ fs.mkdirSync(path.dirname(file), { recursive: true });
259
+ fs.writeFileSync(file, `${JSON.stringify(bundle, null, 2)}\n`, "utf-8");
260
+ }
261
+
262
+ function cachePath(options: ChalinPathsOptions, key: string): string {
263
+ return path.join(resolveChalinPaths(options).projectRoot, ".pi-chalin", "cache", "webfetch", `${key}.json`);
264
+ }
265
+
266
+ function cacheKey(kind: string, payload: unknown): string {
267
+ return `${kind}-${crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 24)}`;
268
+ }
269
+
270
+ function normalizeContent(text: string): string {
271
+ return text.replace(/\s+/g, " ").trim();
272
+ }
273
+
274
+ function truncate(text: string, max: number): string {
275
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
276
+ }
277
+
278
+ function freshnessIcon(freshness: WebFetchAuditFreshness): string {
279
+ if (freshness === "fresh") return "✓";
280
+ if (freshness === "stale") return "!";
281
+ return "·";
282
+ }
283
+
284
+ function formatAge(ms: number): string {
285
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
286
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
287
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
288
+ return `${Math.round(ms / 86_400_000)}d`;
289
+ }
290
+
291
+ function clampInteger(value: number | undefined, min: number, max: number, fallback: number): number {
292
+ if (!Number.isFinite(value)) return fallback;
293
+ return Math.max(min, Math.min(max, Math.floor(value!)));
294
+ }
@@ -0,0 +1,113 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { ChalinPathsOptions } from "./paths.ts";
5
+ import type { AgentDefinition, AgentStep } from "./schemas.ts";
6
+
7
+ export interface WorktreeIsolationPlan {
8
+ enabled: boolean;
9
+ reason: string;
10
+ worktrees: Array<{ stepId: string; agent: string; path: string; branch: string }>;
11
+ warnings: string[];
12
+ }
13
+
14
+ export interface WorktreeMergeResult {
15
+ applied: string[];
16
+ conflicts: Array<{ agent: string; stepId?: string; reason: string; patch?: string; worktreePath?: string }>;
17
+ warnings: string[];
18
+ }
19
+
20
+ export function needsWorktreeIsolation(steps: AgentStep[], agents: Map<string, AgentDefinition>): boolean {
21
+ return steps.filter((step) => isWriterAgent(agents.get(step.agent))).length > 1;
22
+ }
23
+
24
+ export function prepareWorktreeIsolation(options: ChalinPathsOptions & { runId: string; steps: AgentStep[]; agents: Map<string, AgentDefinition> }): WorktreeIsolationPlan {
25
+ if (!needsWorktreeIsolation(options.steps, options.agents)) {
26
+ return { enabled: false, reason: "No parallel writer contention detected.", worktrees: [], warnings: [] };
27
+ }
28
+ const warnings: string[] = [];
29
+ const gitRoot = git(options.cwd, ["rev-parse", "--show-toplevel"]);
30
+ if (!gitRoot.ok) return { enabled: false, reason: "Parallel writers require a git repository for worktree isolation.", worktrees: [], warnings: [gitRoot.stderr] };
31
+ const status = git(options.cwd, ["status", "--porcelain"]);
32
+ if (!status.ok) return { enabled: false, reason: "Could not inspect git status before worktree isolation.", worktrees: [], warnings: [status.stderr] };
33
+ if (status.stdout.trim()) {
34
+ warnings.push("Dirty primary worktree detected; isolated writer patches will be checked against the current local state before applying.");
35
+ }
36
+
37
+ const repoRoot = gitRoot.stdout.trim();
38
+ const baseDir = path.join(path.dirname(repoRoot), ".pi-chalin-worktrees", safeName(path.basename(repoRoot)), options.runId);
39
+ fs.mkdirSync(baseDir, { recursive: true });
40
+ const worktrees: WorktreeIsolationPlan["worktrees"] = [];
41
+ for (const [index, step] of options.steps.entries()) {
42
+ if (!isWriterAgent(options.agents.get(step.agent))) continue;
43
+ const stepId = `step-${index + 1}`;
44
+ const branch = `pi-chalin/${options.runId}/${stepId}-${safeName(step.agent)}`;
45
+ const target = path.join(baseDir, `${stepId}-${safeName(step.agent)}`);
46
+ const branchResult = git(options.cwd, ["branch", branch, "HEAD"]);
47
+ if (!branchResult.ok && !branchResult.stderr.includes("already exists")) {
48
+ warnings.push(`Could not create branch ${branch}: ${branchResult.stderr}`);
49
+ continue;
50
+ }
51
+ const add = git(options.cwd, ["worktree", "add", target, branch]);
52
+ if (!add.ok) {
53
+ warnings.push(`Could not create worktree for ${step.agent}: ${add.stderr}`);
54
+ continue;
55
+ }
56
+ worktrees.push({ stepId, agent: step.agent, path: target, branch });
57
+ }
58
+ return worktrees.length > 0
59
+ ? { enabled: true, reason: "Parallel writer worktrees created; merge patches back sequentially after review.", worktrees, warnings }
60
+ : { enabled: false, reason: "No writer worktrees could be created.", worktrees, warnings };
61
+ }
62
+
63
+ export function mergeWorktreeChanges(options: { cwd: string; plan: WorktreeIsolationPlan }): WorktreeMergeResult {
64
+ const applied: string[] = [];
65
+ const conflicts: WorktreeMergeResult["conflicts"] = [];
66
+ const warnings: string[] = [];
67
+ for (const worktree of options.plan.worktrees) {
68
+ const markNewFiles = git(worktree.path, ["add", "-N", "--", "."]);
69
+ if (!markNewFiles.ok) warnings.push(`Could not mark new files for ${worktree.agent}: ${markNewFiles.stderr}`);
70
+ const diff = git(worktree.path, ["diff", "--binary", "HEAD"]);
71
+ if (!diff.ok) {
72
+ conflicts.push({ agent: worktree.agent, stepId: worktree.stepId, reason: diff.stderr || "could not read worktree diff", worktreePath: worktree.path });
73
+ continue;
74
+ }
75
+ if (!diff.stdout.trim()) {
76
+ warnings.push(`${worktree.agent} produced no file diff.`);
77
+ continue;
78
+ }
79
+ const check = spawnSync("git", ["apply", "--3way", "--check"], { cwd: options.cwd, input: diff.stdout, encoding: "utf-8" });
80
+ if (check.status !== 0) {
81
+ conflicts.push({ agent: worktree.agent, stepId: worktree.stepId, reason: check.stderr || "patch would not apply cleanly", patch: diff.stdout, worktreePath: worktree.path });
82
+ continue;
83
+ }
84
+ const apply = spawnSync("git", ["apply", "--3way"], { cwd: options.cwd, input: diff.stdout, encoding: "utf-8" });
85
+ if (apply.status !== 0) conflicts.push({ agent: worktree.agent, stepId: worktree.stepId, reason: apply.stderr || "patch apply failed", patch: diff.stdout, worktreePath: worktree.path });
86
+ else applied.push(worktree.agent);
87
+ }
88
+ return { applied, conflicts, warnings };
89
+ }
90
+
91
+ export function cleanupWorktrees(options: { cwd: string; plan: WorktreeIsolationPlan }): string[] {
92
+ const warnings: string[] = [];
93
+ for (const worktree of options.plan.worktrees) {
94
+ const remove = git(options.cwd, ["worktree", "remove", "--force", worktree.path]);
95
+ if (!remove.ok) warnings.push(`Could not remove worktree ${worktree.path}: ${remove.stderr}`);
96
+ const branch = git(options.cwd, ["branch", "-D", worktree.branch]);
97
+ if (!branch.ok) warnings.push(`Could not delete branch ${worktree.branch}: ${branch.stderr}`);
98
+ }
99
+ return warnings;
100
+ }
101
+
102
+ function isWriterAgent(agent: AgentDefinition | undefined): boolean {
103
+ return Boolean(agent?.capabilities.includes("edit-files") || agent?.capabilities.includes("write-new-files"));
104
+ }
105
+
106
+ function git(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
107
+ const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
108
+ return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr.trim() };
109
+ }
110
+
111
+ function safeName(value: string): string {
112
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
113
+ }