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.
- package/README.md +264 -0
- package/agents/conflict-resolver.md +28 -0
- package/agents/context-builder.md +31 -0
- package/agents/delegate.md +28 -0
- package/agents/oracle.md +28 -0
- package/agents/planner.md +28 -0
- package/agents/researcher.md +29 -0
- package/agents/reviewer.md +30 -0
- package/agents/scout.md +32 -0
- package/agents/worker.md +29 -0
- package/package.json +91 -0
- package/src/agent-overrides.ts +12 -0
- package/src/agents.ts +274 -0
- package/src/artifacts.ts +326 -0
- package/src/autoroute.ts +274 -0
- package/src/budget.ts +333 -0
- package/src/child-sessions.ts +108 -0
- package/src/child-tools.ts +796 -0
- package/src/commands.ts +140 -0
- package/src/config.ts +189 -0
- package/src/discovery.ts +190 -0
- package/src/index.ts +40 -0
- package/src/interview.ts +202 -0
- package/src/kernel.ts +254 -0
- package/src/memory.ts +945 -0
- package/src/model-resolution.ts +106 -0
- package/src/orchestration.ts +99 -0
- package/src/paths.ts +50 -0
- package/src/route-format.ts +149 -0
- package/src/route-guards.ts +92 -0
- package/src/route-widget.ts +219 -0
- package/src/runner-prompt.ts +346 -0
- package/src/runner-state.ts +105 -0
- package/src/runner.ts +1185 -0
- package/src/runtime-state.ts +175 -0
- package/src/schemas.ts +316 -0
- package/src/snapshot.ts +282 -0
- package/src/sql-js-fts5.d.ts +4 -0
- package/src/tools.ts +558 -0
- package/src/ui-agents.ts +338 -0
- package/src/ui-status.ts +87 -0
- package/src/ui.ts +875 -0
- package/src/webfetch.ts +294 -0
- package/src/worktrees.ts +113 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
createEditToolDefinition,
|
|
6
|
+
createFindToolDefinition,
|
|
7
|
+
createGrepToolDefinition,
|
|
8
|
+
createLsToolDefinition,
|
|
9
|
+
createReadToolDefinition,
|
|
10
|
+
createWriteToolDefinition,
|
|
11
|
+
defineTool,
|
|
12
|
+
type ToolDefinition,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { ArtifactStore, type ArtifactFeatureStatus } from "./artifacts.ts";
|
|
16
|
+
import type { BudgetPolicy } from "./budget.ts";
|
|
17
|
+
import { buildProjectDiscoveryIndex, formatProjectDiscoveryIndex } from "./discovery.ts";
|
|
18
|
+
import { createMemoryCandidate, MemoryStore } from "./memory.ts";
|
|
19
|
+
import { buildProjectSnapshot, formatProjectSnapshot } from "./snapshot.ts";
|
|
20
|
+
import { fetchWebUrls, formatWebBundle, searchWeb } from "./webfetch.ts";
|
|
21
|
+
|
|
22
|
+
const SnapshotParams = Type.Object({});
|
|
23
|
+
const DiscoveryParams = Type.Object({
|
|
24
|
+
maxDepth: Type.Optional(Type.Number({ description: "Maximum directory depth to index. Default 4." })),
|
|
25
|
+
maxEntries: Type.Optional(Type.Number({ description: "Maximum entries to return. Default 450." })),
|
|
26
|
+
});
|
|
27
|
+
const BashParams = Type.Object({
|
|
28
|
+
command: Type.String({ description: "Safe shell command to execute" }),
|
|
29
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const ChalinArtifactWriteParams = Type.Object({
|
|
33
|
+
kind: Type.Union([Type.Literal("checkpoint"), Type.Literal("validation-contract"), Type.Literal("worker-skill"), Type.Literal("feature-state")]),
|
|
34
|
+
featureId: Type.String({ description: "Stable feature/task artifact id." }),
|
|
35
|
+
title: Type.Optional(Type.String({ description: "Checkpoint or validation title." })),
|
|
36
|
+
summary: Type.Optional(Type.String({ description: "Compact human-readable summary. No raw logs/code." })),
|
|
37
|
+
status: Type.Optional(Type.Union([Type.Literal("active"), Type.Literal("complete"), Type.Literal("failed"), Type.Literal("paused")])),
|
|
38
|
+
stage: Type.Optional(Type.String()),
|
|
39
|
+
agent: Type.Optional(Type.String()),
|
|
40
|
+
id: Type.Optional(Type.String({ description: "Validation contract id." })),
|
|
41
|
+
commands: Type.Optional(Type.Array(Type.String())),
|
|
42
|
+
successCriteria: Type.Optional(Type.Array(Type.String())),
|
|
43
|
+
files: Type.Optional(Type.Array(Type.String())),
|
|
44
|
+
name: Type.Optional(Type.String({ description: "Worker skill name." })),
|
|
45
|
+
rules: Type.Optional(Type.Array(Type.String())),
|
|
46
|
+
chain: Type.Optional(Type.Array(Type.String())),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
type ChalinArtifactWriteParamsShape = {
|
|
50
|
+
kind: "checkpoint" | "validation-contract" | "worker-skill" | "feature-state";
|
|
51
|
+
featureId: string;
|
|
52
|
+
title?: string;
|
|
53
|
+
summary?: string;
|
|
54
|
+
status?: ArtifactFeatureStatus;
|
|
55
|
+
stage?: string;
|
|
56
|
+
agent?: string;
|
|
57
|
+
id?: string;
|
|
58
|
+
commands?: string[];
|
|
59
|
+
successCriteria?: string[];
|
|
60
|
+
files?: string[];
|
|
61
|
+
name?: string;
|
|
62
|
+
rules?: string[];
|
|
63
|
+
chain?: string[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const ChalinWebSearchParams = Type.Object({
|
|
67
|
+
query: Type.Optional(Type.String({ description: "Web search query." })),
|
|
68
|
+
url: Type.Optional(Type.String({ description: "Single URL to fetch." })),
|
|
69
|
+
urls: Type.Optional(Type.Array(Type.String(), { description: "URLs to fetch." })),
|
|
70
|
+
maxSources: Type.Optional(Type.Number({ description: "Maximum search sources." })),
|
|
71
|
+
depth: Type.Optional(Type.Union([Type.Literal("snippets"), Type.Literal("content")])),
|
|
72
|
+
freshness: Type.Optional(Type.Union([Type.Literal("cache-ok"), Type.Literal("prefer-fresh"), Type.Literal("must-be-fresh")])),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const ChalinMemorySearchParams = Type.Object({
|
|
76
|
+
query: Type.String({ description: "Concrete task, decision, file, or concept to recall. Keep it short." }),
|
|
77
|
+
limit: Type.Optional(Type.Number({ description: "Maximum memories to consider. Default 8." })),
|
|
78
|
+
tokenBudget: Type.Optional(Type.Number({ description: "Maximum returned memory context tokens. Default is per-agent and capped." })),
|
|
79
|
+
includeEvidence: Type.Optional(Type.Boolean({ description: "Include compact evidence snippets when needed for contradiction or review work." })),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const ChalinMemoryWriteParams = Type.Object({
|
|
83
|
+
category: Type.String({ description: "Memory category, e.g. project-fact, pattern, tooling, testing, workflow, bugfix, decision, preference, architecture, safety, security, failure." }),
|
|
84
|
+
content: Type.String({ description: "Durable human-readable memory. No logs, code dumps, command output, or task completion notes." }),
|
|
85
|
+
confidence: Type.Optional(Type.Number({ description: "Confidence from 0 to 1. Defaults to 0.8." })),
|
|
86
|
+
evidence: Type.Optional(Type.String({ description: "Compact evidence source, file path, command, or reason. No raw logs." })),
|
|
87
|
+
topicKey: Type.Optional(Type.String({ description: "Optional stable topic key when correcting or merging a known concept." })),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const ChalinMemoryReviseParams = Type.Object({
|
|
91
|
+
id: Type.String({ description: "Existing memory id to revise." }),
|
|
92
|
+
content: Type.String({ description: "Corrected durable memory content." }),
|
|
93
|
+
category: Type.Optional(Type.String({ description: "Corrected category if it changed." })),
|
|
94
|
+
confidence: Type.Optional(Type.Number({ description: "Confidence from 0 to 1. Defaults to the existing record confidence." })),
|
|
95
|
+
evidence: Type.Optional(Type.String({ description: "Compact evidence proving the correction." })),
|
|
96
|
+
reason: Type.Optional(Type.String({ description: "Why the old memory is stale, wrong, or less useful." })),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
type ChalinMemorySearchParamsShape = {
|
|
100
|
+
query: string;
|
|
101
|
+
limit?: number;
|
|
102
|
+
tokenBudget?: number;
|
|
103
|
+
includeEvidence?: boolean;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type ChalinMemoryWriteParamsShape = {
|
|
107
|
+
category: string;
|
|
108
|
+
content: string;
|
|
109
|
+
confidence?: number;
|
|
110
|
+
evidence?: string;
|
|
111
|
+
topicKey?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type ChalinMemoryReviseParamsShape = {
|
|
115
|
+
id: string;
|
|
116
|
+
content: string;
|
|
117
|
+
category?: string;
|
|
118
|
+
confidence?: number;
|
|
119
|
+
evidence?: string;
|
|
120
|
+
reason?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
interface BashGuardDetails {
|
|
124
|
+
blocked: boolean;
|
|
125
|
+
reason?: string;
|
|
126
|
+
command: string;
|
|
127
|
+
exitCode?: number | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ChildToolPolicyOptions {
|
|
131
|
+
cwd: string;
|
|
132
|
+
maxToolCalls: number;
|
|
133
|
+
budgetPolicy?: BudgetPolicy;
|
|
134
|
+
agentName?: string;
|
|
135
|
+
allowedTools?: string[];
|
|
136
|
+
priorFilesRead?: string[];
|
|
137
|
+
maxCrossStepDuplicateReads?: number;
|
|
138
|
+
onActivity?: (activity: ChildToolActivity) => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ChildToolActivity {
|
|
142
|
+
toolName: string;
|
|
143
|
+
phase: "start" | "end" | "blocked";
|
|
144
|
+
at: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ChildToolPolicyMetrics {
|
|
148
|
+
toolCalls: number;
|
|
149
|
+
toolCallsByName: Record<string, number>;
|
|
150
|
+
policyViolations: string[];
|
|
151
|
+
budgetStopCount: number;
|
|
152
|
+
duplicateReadCount: number;
|
|
153
|
+
filesRead: string[];
|
|
154
|
+
readBytes: number;
|
|
155
|
+
outputChars: number;
|
|
156
|
+
outputTruncatedCount: number;
|
|
157
|
+
filesTouched: string[];
|
|
158
|
+
retriesByTool: Record<string, number>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ChildToolPolicy {
|
|
162
|
+
cwd: string;
|
|
163
|
+
maxToolCalls: number;
|
|
164
|
+
agentName?: string;
|
|
165
|
+
allowedTools: Set<string>;
|
|
166
|
+
beforeTool(toolName: string, params: Record<string, unknown>): { allowed: true } | { allowed: false; reason: string };
|
|
167
|
+
afterTool(toolName: string, result: unknown): unknown;
|
|
168
|
+
metrics(): ChildToolPolicyMetrics;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createChildToolPolicy(options: ChildToolPolicyOptions): ChildToolPolicy {
|
|
172
|
+
const toolCallsByName: Record<string, number> = {};
|
|
173
|
+
const policyViolations: string[] = [];
|
|
174
|
+
const filesRead: string[] = [];
|
|
175
|
+
const filesTouched: string[] = [];
|
|
176
|
+
const retriesByTool: Record<string, number> = {};
|
|
177
|
+
const allowedTools = new Set(options.allowedTools ?? []);
|
|
178
|
+
const priorFilesRead = new Set((options.priorFilesRead ?? []).map((item) => normalizeMetricPath(item, options.cwd)));
|
|
179
|
+
const maxCrossStepDuplicateReads = options.maxCrossStepDuplicateReads ?? Number.POSITIVE_INFINITY;
|
|
180
|
+
const hasExplicitAllowlist = options.allowedTools !== undefined;
|
|
181
|
+
let budgetStopCount = 0;
|
|
182
|
+
let toolCalls = 0;
|
|
183
|
+
let readBytes = 0;
|
|
184
|
+
let outputChars = 0;
|
|
185
|
+
let outputTruncatedCount = 0;
|
|
186
|
+
let crossStepDuplicateReadCount = 0;
|
|
187
|
+
const startedAt = Date.now();
|
|
188
|
+
const caps = options.budgetPolicy?.caps ?? {
|
|
189
|
+
maxToolCalls: options.maxToolCalls,
|
|
190
|
+
maxSeconds: Number.POSITIVE_INFINITY,
|
|
191
|
+
maxUsd: Number.POSITIVE_INFINITY,
|
|
192
|
+
maxTurns: Number.POSITIVE_INFINITY,
|
|
193
|
+
maxOutputChars: 12_000,
|
|
194
|
+
maxReadBytes: 120_000,
|
|
195
|
+
maxFilesTouched: 8,
|
|
196
|
+
maxRetriesPerTool: 2,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function violation(reason: string): { allowed: false; reason: string } {
|
|
200
|
+
policyViolations.push(reason);
|
|
201
|
+
return { allowed: false, reason };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function activity(toolName: string, phase: ChildToolActivity["phase"]): void {
|
|
205
|
+
options.onActivity?.({ toolName, phase, at: Date.now() });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function record(toolName: string, params: Record<string, unknown>): { allowed: true } {
|
|
209
|
+
toolCalls += 1;
|
|
210
|
+
toolCallsByName[toolName] = (toolCallsByName[toolName] ?? 0) + 1;
|
|
211
|
+
if (toolName === "read") {
|
|
212
|
+
const readPath = getPathParam(params);
|
|
213
|
+
if (readPath) filesRead.push(normalizeMetricPath(readPath, options.cwd));
|
|
214
|
+
}
|
|
215
|
+
if (toolName === "edit" || toolName === "write") {
|
|
216
|
+
const target = getPathParam(params);
|
|
217
|
+
if (target) filesTouched.push(normalizeMetricPath(target, options.cwd));
|
|
218
|
+
}
|
|
219
|
+
return { allowed: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
cwd: options.cwd,
|
|
224
|
+
maxToolCalls: caps.maxToolCalls,
|
|
225
|
+
agentName: options.agentName,
|
|
226
|
+
allowedTools,
|
|
227
|
+
beforeTool(toolName, params) {
|
|
228
|
+
if (hasExplicitAllowlist && !allowedTools.has(toolName)) {
|
|
229
|
+
activity(toolName, "blocked");
|
|
230
|
+
return violation(`tool_not_allowed:${toolName}`);
|
|
231
|
+
}
|
|
232
|
+
if (Date.now() - startedAt >= caps.maxSeconds * 1000) {
|
|
233
|
+
budgetStopCount += 1;
|
|
234
|
+
activity(toolName, "blocked");
|
|
235
|
+
return { allowed: false, reason: `budget_exceeded:${toolName}:max_seconds=${caps.maxSeconds}` };
|
|
236
|
+
}
|
|
237
|
+
if (toolCalls >= caps.maxToolCalls) {
|
|
238
|
+
budgetStopCount += 1;
|
|
239
|
+
activity(toolName, "blocked");
|
|
240
|
+
return { allowed: false, reason: `budget_exceeded:${toolName}:max_tool_calls=${caps.maxToolCalls}` };
|
|
241
|
+
}
|
|
242
|
+
if (readBytes >= caps.maxReadBytes) {
|
|
243
|
+
budgetStopCount += 1;
|
|
244
|
+
activity(toolName, "blocked");
|
|
245
|
+
return { allowed: false, reason: `budget_exceeded:${toolName}:max_read_bytes=${caps.maxReadBytes}` };
|
|
246
|
+
}
|
|
247
|
+
if (filesTouched.length >= caps.maxFilesTouched && (toolName === "edit" || toolName === "write")) {
|
|
248
|
+
budgetStopCount += 1;
|
|
249
|
+
activity(toolName, "blocked");
|
|
250
|
+
return { allowed: false, reason: `budget_exceeded:${toolName}:max_files_touched=${caps.maxFilesTouched}` };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (toolName === "bash") {
|
|
254
|
+
const command = typeof params.command === "string" ? params.command : "";
|
|
255
|
+
const verdict = classifyBashCommand(command);
|
|
256
|
+
if (!verdict.allowed) {
|
|
257
|
+
activity(toolName, "blocked");
|
|
258
|
+
return violation(`bash_policy:${verdict.reason ?? "blocked"}:${command.slice(0, 140)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (toolName === "write") {
|
|
263
|
+
const target = getPathParam(params);
|
|
264
|
+
if (target && fs.existsSync(resolveProjectPath(target, options.cwd))) {
|
|
265
|
+
activity(toolName, "blocked");
|
|
266
|
+
return violation(`write_existing_file:${normalizeMetricPath(target, options.cwd)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (toolName === "read") {
|
|
271
|
+
const readPath = getPathParam(params);
|
|
272
|
+
const normalizedReadPath = readPath ? normalizeMetricPath(readPath, options.cwd) : undefined;
|
|
273
|
+
if (normalizedReadPath && priorFilesRead.has(normalizedReadPath) && crossStepDuplicateReadCount >= maxCrossStepDuplicateReads) {
|
|
274
|
+
budgetStopCount += 1;
|
|
275
|
+
activity(toolName, "blocked");
|
|
276
|
+
return { allowed: false, reason: `budget_exceeded:read:cross_step_duplicate_reads=${maxCrossStepDuplicateReads}:${normalizedReadPath}` };
|
|
277
|
+
}
|
|
278
|
+
if (normalizedReadPath && priorFilesRead.has(normalizedReadPath)) crossStepDuplicateReadCount += 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (toolName === "edit") {
|
|
282
|
+
const edits = Array.isArray(params.edits) ? params.edits : [];
|
|
283
|
+
const tooLarge = edits.some((edit) => {
|
|
284
|
+
if (!edit || typeof edit !== "object") return false;
|
|
285
|
+
const item = edit as Record<string, unknown>;
|
|
286
|
+
return stringSize(item.oldText) > 12_000 || stringSize(item.newText) > 12_000;
|
|
287
|
+
});
|
|
288
|
+
if (tooLarge) {
|
|
289
|
+
activity(toolName, "blocked");
|
|
290
|
+
return violation(`large_edit_block:${normalizeMetricPath(getPathParam(params) ?? "unknown", options.cwd)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const recorded = record(toolName, params);
|
|
295
|
+
activity(toolName, "start");
|
|
296
|
+
return recorded;
|
|
297
|
+
},
|
|
298
|
+
afterTool(toolName, result) {
|
|
299
|
+
const compressed = compressToolResult(result, toolName, caps.maxOutputChars);
|
|
300
|
+
if (compressed.truncated) outputTruncatedCount += 1;
|
|
301
|
+
outputChars += compressed.outputChars;
|
|
302
|
+
if (toolName === "read") readBytes += compressed.outputChars;
|
|
303
|
+
activity(toolName, "end");
|
|
304
|
+
return compressed.result;
|
|
305
|
+
},
|
|
306
|
+
metrics() {
|
|
307
|
+
return {
|
|
308
|
+
toolCalls,
|
|
309
|
+
toolCallsByName: { ...toolCallsByName },
|
|
310
|
+
policyViolations: [...policyViolations],
|
|
311
|
+
budgetStopCount,
|
|
312
|
+
duplicateReadCount: filesRead.length - new Set(filesRead).size,
|
|
313
|
+
filesRead: [...new Set(filesRead)].slice(0, 50),
|
|
314
|
+
readBytes,
|
|
315
|
+
outputChars,
|
|
316
|
+
outputTruncatedCount,
|
|
317
|
+
filesTouched: [...new Set(filesTouched)].slice(0, 50),
|
|
318
|
+
retriesByTool: { ...retriesByTool },
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function createChildTools(policy: ChildToolPolicy): ToolDefinition[] {
|
|
325
|
+
const builtinTools: Array<[string, ToolDefinition<any, any, any>]> = [
|
|
326
|
+
["read", createReadToolDefinition(policy.cwd)],
|
|
327
|
+
["grep", createGrepToolDefinition(policy.cwd)],
|
|
328
|
+
["find", createFindToolDefinition(policy.cwd)],
|
|
329
|
+
["ls", createLsToolDefinition(policy.cwd)],
|
|
330
|
+
["edit", createEditToolDefinition(policy.cwd)],
|
|
331
|
+
["write", createWriteToolDefinition(policy.cwd)],
|
|
332
|
+
];
|
|
333
|
+
const tools: Array<[string, ToolDefinition<any, any, any>]> = [
|
|
334
|
+
...builtinTools.map(([name, tool]) => [name, guardTool(tool, name, policy)] as [string, ToolDefinition<any, any, any>]),
|
|
335
|
+
["chalin_project_discovery", createProjectDiscoveryTool(policy)],
|
|
336
|
+
["chalin_project_snapshot", createProjectSnapshotTool(policy)],
|
|
337
|
+
["bash", createGuardedBashTool(policy)],
|
|
338
|
+
["chalin_web_search", createChalinWebSearchTool(policy)],
|
|
339
|
+
["chalin_artifact_write", createChalinArtifactWriteTool(policy)],
|
|
340
|
+
["chalin_memory_search", createChalinMemorySearchTool(policy)],
|
|
341
|
+
["chalin_memory_write", createChalinMemoryWriteTool(policy)],
|
|
342
|
+
["chalin_memory_revise", createChalinMemoryReviseTool(policy)],
|
|
343
|
+
];
|
|
344
|
+
return tools.filter(([name]) => policy.allowedTools.has(name)).map(([, tool]) => tool);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function createProjectDiscoveryTool(policy: ChildToolPolicy): ToolDefinition {
|
|
348
|
+
return defineTool<typeof DiscoveryParams, unknown>({
|
|
349
|
+
name: "chalin_project_discovery",
|
|
350
|
+
label: "Chalin Project Discovery",
|
|
351
|
+
description: "Return a raw, stack-agnostic project file index with shallow files, config-like files, test-like files, directories, and extension histogram. It does not infer architecture or framework.",
|
|
352
|
+
promptSnippet: "chalin_project_discovery: get a raw non-semantic file index before deciding which files to inspect.",
|
|
353
|
+
promptGuidelines: [
|
|
354
|
+
"Call chalin_project_discovery before broad repository exploration.",
|
|
355
|
+
"Use it as an index, not as proof of architecture.",
|
|
356
|
+
"Read evidence files before making project claims.",
|
|
357
|
+
],
|
|
358
|
+
parameters: DiscoveryParams,
|
|
359
|
+
async execute(_toolCallId, params) {
|
|
360
|
+
const gate = policy.beforeTool("chalin_project_discovery", params);
|
|
361
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
362
|
+
const index = buildProjectDiscoveryIndex(policy.cwd, {
|
|
363
|
+
maxDepth: typeof params.maxDepth === "number" ? params.maxDepth : undefined,
|
|
364
|
+
maxEntries: typeof params.maxEntries === "number" ? params.maxEntries : undefined,
|
|
365
|
+
});
|
|
366
|
+
return policy.afterTool("chalin_project_discovery", {
|
|
367
|
+
content: [{ type: "text" as const, text: formatProjectDiscoveryIndex(index) }],
|
|
368
|
+
details: { index },
|
|
369
|
+
}) as never;
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function createProjectSnapshotTool(policy: ChildToolPolicy): ToolDefinition {
|
|
375
|
+
return defineTool<typeof SnapshotParams, unknown>({
|
|
376
|
+
name: "chalin_project_snapshot",
|
|
377
|
+
label: "Chalin Project Snapshot",
|
|
378
|
+
description: "Return a stack-agnostic, cached project snapshot: stack signals, test/build commands, entrypoints, high-signal files, and git context.",
|
|
379
|
+
promptSnippet: "chalin_project_snapshot: get cached stack/project/git context before manual repository exploration.",
|
|
380
|
+
promptGuidelines: [
|
|
381
|
+
"Call chalin_project_snapshot before broad repository exploration.",
|
|
382
|
+
"Use the snapshot to choose high-signal files instead of scanning the whole repository.",
|
|
383
|
+
],
|
|
384
|
+
parameters: SnapshotParams,
|
|
385
|
+
async execute() {
|
|
386
|
+
const gate = policy.beforeTool("chalin_project_snapshot", {});
|
|
387
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
388
|
+
const snapshot = buildProjectSnapshot({ cwd: policy.cwd });
|
|
389
|
+
return policy.afterTool("chalin_project_snapshot", {
|
|
390
|
+
content: [{ type: "text" as const, text: formatProjectSnapshot(snapshot) }],
|
|
391
|
+
details: { snapshot },
|
|
392
|
+
}) as never;
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function createGuardedBashTool(policy: ChildToolPolicy): ToolDefinition {
|
|
398
|
+
return defineTool<typeof BashParams, BashGuardDetails>({
|
|
399
|
+
name: "bash",
|
|
400
|
+
label: "bash (guarded)",
|
|
401
|
+
description: "Run only safe inspection or explicit validation commands. Scripts, file writes, and generated readers/modifiers are blocked.",
|
|
402
|
+
promptSnippet: "bash: guarded shell for git/status/list/search/test commands only; do not create scripts or modify files.",
|
|
403
|
+
promptGuidelines: [
|
|
404
|
+
"Use read/find/grep/ls/edit before bash when possible.",
|
|
405
|
+
"Do not create Python/Node/shell scripts to inspect or modify files.",
|
|
406
|
+
"Do not use bash for file edits. Use edit for targeted changes.",
|
|
407
|
+
],
|
|
408
|
+
parameters: BashParams,
|
|
409
|
+
async execute(_toolCallId, params, signal) {
|
|
410
|
+
const gate = policy.beforeTool("bash", params);
|
|
411
|
+
if (!gate.allowed) {
|
|
412
|
+
return {
|
|
413
|
+
content: [{ type: "text" as const, text: `Blocked by pi-chalin child policy: ${gate.reason}\nUse Pi-native read/find/grep/ls/edit tools instead, or stop and report partial findings.` }],
|
|
414
|
+
details: { blocked: true, reason: gate.reason, command: params.command },
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const verdict = classifyBashCommand(params.command);
|
|
418
|
+
if (!verdict.allowed) {
|
|
419
|
+
return {
|
|
420
|
+
content: [{ type: "text" as const, text: `Blocked by pi-chalin child bash guard: ${verdict.reason}\nUse Pi-native read/find/grep/ls/edit tools instead.` }],
|
|
421
|
+
details: { blocked: true, reason: verdict.reason, command: params.command },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const result = await runGuardedCommand(params.command, policy.cwd, Math.min(Math.max(params.timeout ?? 10, 1), 30), signal);
|
|
425
|
+
return policy.afterTool("bash", {
|
|
426
|
+
content: [{ type: "text" as const, text: result.text }],
|
|
427
|
+
details: { blocked: false, reason: undefined, exitCode: result.exitCode, command: params.command },
|
|
428
|
+
}) as never;
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
export function createChalinWebSearchTool(policy: ChildToolPolicy): ToolDefinition {
|
|
435
|
+
return defineTool<typeof ChalinWebSearchParams, unknown>({
|
|
436
|
+
name: "chalin_web_search",
|
|
437
|
+
label: "Chalin Web Search",
|
|
438
|
+
description: "Search or fetch current web context through Exa MCP. Available only to agents with external-context capability.",
|
|
439
|
+
promptSnippet: "chalin_web_search: fetch compact external evidence through Exa MCP when authorized.",
|
|
440
|
+
promptGuidelines: [
|
|
441
|
+
"Use chalin_web_search only when current external docs/facts or a URL are needed.",
|
|
442
|
+
"Return source URLs in the handoff; do not paste raw dumps.",
|
|
443
|
+
],
|
|
444
|
+
parameters: ChalinWebSearchParams,
|
|
445
|
+
async execute(_toolCallId, params, signal) {
|
|
446
|
+
const input = isRecord(params) ? params : {};
|
|
447
|
+
const gate = policy.beforeTool("chalin_web_search", input);
|
|
448
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
449
|
+
const urls = [
|
|
450
|
+
...(Array.isArray(params.urls) ? params.urls : []),
|
|
451
|
+
...(typeof params.url === "string" ? [params.url] : []),
|
|
452
|
+
].filter((url): url is string => typeof url === "string" && url.trim().length > 0);
|
|
453
|
+
const freshness = params.freshness as "cache-ok" | "prefer-fresh" | "must-be-fresh" | undefined;
|
|
454
|
+
const bundle = urls.length > 0
|
|
455
|
+
? await fetchWebUrls({ cwd: policy.cwd, urls, freshness, signal })
|
|
456
|
+
: await searchWeb({ cwd: policy.cwd, query: String(params.query ?? ""), maxSources: Number(params.maxSources ?? 5), depth: params.depth as "snippets" | "content" | undefined, freshness, signal });
|
|
457
|
+
return policy.afterTool("chalin_web_search", { content: [{ type: "text" as const, text: formatWebBundle(bundle) }], details: bundle }) as never;
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function createChalinMemorySearchTool(policy: ChildToolPolicy): ToolDefinition {
|
|
463
|
+
return defineTool<typeof ChalinMemorySearchParams, unknown>({
|
|
464
|
+
name: "chalin_memory_search",
|
|
465
|
+
label: "Chalin Memory Search",
|
|
466
|
+
description: "Retrieve compact project/user memory autonomously when it can reduce exploration, prevent repeated mistakes, or check prior decisions. Results are token-budgeted.",
|
|
467
|
+
promptSnippet: "chalin_memory_search: recall compact durable memory without waiting for an explicit human instruction.",
|
|
468
|
+
promptGuidelines: [
|
|
469
|
+
"Use when prior decisions, preferences, workflows, or repeated project facts may matter.",
|
|
470
|
+
"Keep query short and specific; ask for evidence only when checking contradictions or reviewing risk.",
|
|
471
|
+
"Treat memory as guidance. Current repo evidence wins over stale memory.",
|
|
472
|
+
],
|
|
473
|
+
parameters: ChalinMemorySearchParams,
|
|
474
|
+
async execute(_toolCallId, params: ChalinMemorySearchParamsShape) {
|
|
475
|
+
const input = isRecord(params) ? params : {};
|
|
476
|
+
const gate = policy.beforeTool("chalin_memory_search", input);
|
|
477
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
478
|
+
const store = new MemoryStore({ cwd: policy.cwd });
|
|
479
|
+
const bundle = await store.retrieve({
|
|
480
|
+
query: String(params.query ?? ""),
|
|
481
|
+
sourceAgent: policy.agentName,
|
|
482
|
+
limit: typeof params.limit === "number" ? params.limit : undefined,
|
|
483
|
+
tokenBudget: typeof params.tokenBudget === "number" ? params.tokenBudget : undefined,
|
|
484
|
+
includeEvidence: Boolean(params.includeEvidence),
|
|
485
|
+
});
|
|
486
|
+
const text = bundle.text || "No relevant active memory found.";
|
|
487
|
+
return policy.afterTool("chalin_memory_search", {
|
|
488
|
+
content: [{ type: "text" as const, text }],
|
|
489
|
+
details: { ...bundle, results: bundle.results.map((result) => ({ id: result.record.id, category: result.record.category, score: result.score })) },
|
|
490
|
+
}) as never;
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function createChalinMemoryWriteTool(policy: ChildToolPolicy): ToolDefinition {
|
|
496
|
+
return defineTool<typeof ChalinMemoryWriteParams, unknown>({
|
|
497
|
+
name: "chalin_memory_write",
|
|
498
|
+
label: "Chalin Memory Write",
|
|
499
|
+
description: "Submit a durable memory candidate autonomously through pi-chalin WriteGuard. The store decides active, pending, or rejected.",
|
|
500
|
+
promptSnippet: "chalin_memory_write: save durable, verified project knowledge; never save logs or trivial task notes.",
|
|
501
|
+
promptGuidelines: [
|
|
502
|
+
"Write only knowledge that should help future runs: decisions, durable patterns, project facts, testing/tooling rules, failures, or preferences.",
|
|
503
|
+
"Prefer one compact sentence. Include evidence when the memory corrects or replaces earlier understanding.",
|
|
504
|
+
"Do not write raw logs, commands, stdout/stderr, stack traces, code dumps, or simple completion notes.",
|
|
505
|
+
],
|
|
506
|
+
parameters: ChalinMemoryWriteParams,
|
|
507
|
+
async execute(_toolCallId, params: ChalinMemoryWriteParamsShape) {
|
|
508
|
+
const input = isRecord(params) ? params : {};
|
|
509
|
+
const gate = policy.beforeTool("chalin_memory_write", input);
|
|
510
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
511
|
+
const validation = validateMemoryWriteParams(params);
|
|
512
|
+
if (!validation.allowed) return blockedToolResult(validation.reason);
|
|
513
|
+
const store = new MemoryStore({ cwd: policy.cwd });
|
|
514
|
+
const [record] = await store.submitCandidates([createMemoryCandidate({
|
|
515
|
+
category: params.category,
|
|
516
|
+
content: params.content,
|
|
517
|
+
sourceAgent: policy.agentName ?? "subagent",
|
|
518
|
+
confidence: typeof params.confidence === "number" ? Math.max(0, Math.min(1, params.confidence)) : 0.8,
|
|
519
|
+
evidence: params.evidence,
|
|
520
|
+
scope: "project",
|
|
521
|
+
topicKey: params.topicKey,
|
|
522
|
+
})]);
|
|
523
|
+
const text = record
|
|
524
|
+
? `memory ${record.status}: ${record.id} · ${record.category} · ${truncateForTool(record.content, 220)}`
|
|
525
|
+
: "memory rejected: no candidate was persisted.";
|
|
526
|
+
return policy.afterTool("chalin_memory_write", { content: [{ type: "text" as const, text }], details: { record } }) as never;
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function createChalinMemoryReviseTool(policy: ChildToolPolicy): ToolDefinition {
|
|
532
|
+
return defineTool<typeof ChalinMemoryReviseParams, unknown>({
|
|
533
|
+
name: "chalin_memory_revise",
|
|
534
|
+
label: "Chalin Memory Revise",
|
|
535
|
+
description: "Correct an existing memory when current evidence proves it stale, wrong, or less useful. Revisions are audited.",
|
|
536
|
+
promptSnippet: "chalin_memory_revise: repair stale or wrong memory with evidence.",
|
|
537
|
+
promptGuidelines: [
|
|
538
|
+
"Use only when you have evidence that the previous memory is wrong, stale, or lower quality.",
|
|
539
|
+
"Keep corrected content compact and durable.",
|
|
540
|
+
"Explain why the correction is safer or more accurate.",
|
|
541
|
+
],
|
|
542
|
+
parameters: ChalinMemoryReviseParams,
|
|
543
|
+
async execute(_toolCallId, params: ChalinMemoryReviseParamsShape) {
|
|
544
|
+
const input = isRecord(params) ? params : {};
|
|
545
|
+
const gate = policy.beforeTool("chalin_memory_revise", input);
|
|
546
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
547
|
+
const validation = validateMemoryRevisionParams(params);
|
|
548
|
+
if (!validation.allowed) return blockedToolResult(validation.reason);
|
|
549
|
+
const store = new MemoryStore({ cwd: policy.cwd });
|
|
550
|
+
const record = await store.revise(params.id, {
|
|
551
|
+
category: params.category,
|
|
552
|
+
content: params.content,
|
|
553
|
+
confidence: params.confidence,
|
|
554
|
+
evidence: params.evidence,
|
|
555
|
+
reason: params.reason,
|
|
556
|
+
sourceAgent: policy.agentName ?? "subagent",
|
|
557
|
+
});
|
|
558
|
+
const text = record
|
|
559
|
+
? `memory revised: ${record.id} · ${record.status} · rev=${record.revisionCount} · ${truncateForTool(record.content, 220)}`
|
|
560
|
+
: `memory revise skipped: '${params.id}' was not found or cannot be revised.`;
|
|
561
|
+
return policy.afterTool("chalin_memory_revise", { content: [{ type: "text" as const, text }], details: { record } }) as never;
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
export function createChalinArtifactWriteTool(policy: ChildToolPolicy): ToolDefinition {
|
|
568
|
+
return defineTool<typeof ChalinArtifactWriteParams, unknown>({
|
|
569
|
+
name: "chalin_artifact_write",
|
|
570
|
+
label: "Chalin Artifact Write",
|
|
571
|
+
description: "Write controlled pi-chalin checkpoints, validation contracts, worker skills, or feature state for long-running work.",
|
|
572
|
+
promptSnippet: "chalin_artifact_write: save compact task artifacts for resumable chalin workflows; never store raw logs or code dumps.",
|
|
573
|
+
promptGuidelines: [
|
|
574
|
+
"Use after a meaningful handoff, validation boundary, or worker-specific convention is discovered.",
|
|
575
|
+
"Write compact human-readable summaries only. Do not store raw command output, stack traces, or code dumps.",
|
|
576
|
+
"Prefer validation contracts with explicit commands and success criteria before handing work to another agent.",
|
|
577
|
+
],
|
|
578
|
+
parameters: ChalinArtifactWriteParams,
|
|
579
|
+
async execute(_toolCallId, params: ChalinArtifactWriteParamsShape) {
|
|
580
|
+
const input = isRecord(params) ? params : {};
|
|
581
|
+
const gate = policy.beforeTool("chalin_artifact_write", input);
|
|
582
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
583
|
+
const validation = validateArtifactParams(params);
|
|
584
|
+
if (!validation.allowed) return blockedToolResult(validation.reason);
|
|
585
|
+
const store = new ArtifactStore({ cwd: policy.cwd });
|
|
586
|
+
if (params.kind === "feature-state") {
|
|
587
|
+
const state = await store.initFeature({ featureId: params.featureId, goal: params.summary ?? params.title ?? `Continue ${params.featureId}`, chain: params.chain, currentStep: params.title });
|
|
588
|
+
return artifactToolResult(`feature state saved: ${state.featureId}`, state);
|
|
589
|
+
}
|
|
590
|
+
if (params.kind === "checkpoint") {
|
|
591
|
+
const checkpoint = await store.appendCheckpoint(params.featureId, {
|
|
592
|
+
agent: params.agent ?? policy.agentName ?? "subagent",
|
|
593
|
+
title: params.title ?? "Checkpoint",
|
|
594
|
+
summary: params.summary ?? "Checkpoint recorded.",
|
|
595
|
+
status: params.status ?? "active",
|
|
596
|
+
stage: params.stage,
|
|
597
|
+
});
|
|
598
|
+
return artifactToolResult(`checkpoint saved: ${checkpoint.id}`, checkpoint);
|
|
599
|
+
}
|
|
600
|
+
if (params.kind === "validation-contract") {
|
|
601
|
+
const contract = await store.saveValidationContract(params.featureId, {
|
|
602
|
+
id: params.id ?? params.title ?? "validation-contract",
|
|
603
|
+
title: params.title ?? params.id ?? "Validation contract",
|
|
604
|
+
commands: params.commands ?? [],
|
|
605
|
+
successCriteria: params.successCriteria ?? [],
|
|
606
|
+
files: params.files,
|
|
607
|
+
});
|
|
608
|
+
return artifactToolResult(`validation contract saved: ${contract.id}`, contract);
|
|
609
|
+
}
|
|
610
|
+
const skill = await store.saveWorkerSkill(params.featureId, {
|
|
611
|
+
name: params.name ?? params.title ?? "worker-skill",
|
|
612
|
+
summary: params.summary ?? params.title ?? "Worker skill for this feature.",
|
|
613
|
+
rules: params.rules ?? [],
|
|
614
|
+
});
|
|
615
|
+
return artifactToolResult(`worker skill saved: ${skill.name}`, skill);
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function validateArtifactParams(params: ChalinArtifactWriteParamsShape): { allowed: true } | { allowed: false; reason: string } {
|
|
621
|
+
if (!params.featureId || params.featureId.length > 96) return { allowed: false, reason: "artifact_feature_id_invalid" };
|
|
622
|
+
const text = [params.title, params.summary, ...(params.successCriteria ?? []), ...(params.rules ?? [])].filter(Boolean).join("\n");
|
|
623
|
+
if (text.length > 3000) return { allowed: false, reason: "artifact_payload_too_large" };
|
|
624
|
+
if (/\b(stdout|stderr|traceback|stack trace|returncode|subprocess|os\.environ|sys\.exit)\b/i.test(text)) return { allowed: false, reason: "artifact_raw_runtime_noise" };
|
|
625
|
+
if (params.kind === "checkpoint" && !params.summary) return { allowed: false, reason: "checkpoint_summary_required" };
|
|
626
|
+
if (params.kind === "validation-contract" && (!params.successCriteria?.length || !params.commands?.length)) return { allowed: false, reason: "validation_contract_requires_commands_and_success_criteria" };
|
|
627
|
+
if (params.kind === "worker-skill" && (!params.summary || !params.rules?.length)) return { allowed: false, reason: "worker_skill_requires_summary_and_rules" };
|
|
628
|
+
return { allowed: true };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function validateMemoryWriteParams(params: ChalinMemoryWriteParamsShape): { allowed: true } | { allowed: false; reason: string } {
|
|
632
|
+
const text = [params.category, params.content, params.evidence, params.topicKey].filter(Boolean).join("\n");
|
|
633
|
+
if (!params.category || params.category.length > 40) return { allowed: false, reason: "memory_category_invalid" };
|
|
634
|
+
if (!params.content || params.content.length < 48 || params.content.length > 600) return { allowed: false, reason: "memory_content_must_be_48_to_600_chars" };
|
|
635
|
+
if (text.length > 1200) return { allowed: false, reason: "memory_payload_too_large" };
|
|
636
|
+
if (containsRawRuntimeNoise(text)) return { allowed: false, reason: "memory_raw_runtime_noise" };
|
|
637
|
+
return { allowed: true };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function validateMemoryRevisionParams(params: ChalinMemoryReviseParamsShape): { allowed: true } | { allowed: false; reason: string } {
|
|
641
|
+
const text = [params.id, params.category, params.content, params.evidence, params.reason].filter(Boolean).join("\n");
|
|
642
|
+
if (!params.id || params.id.length > 120) return { allowed: false, reason: "memory_revision_id_invalid" };
|
|
643
|
+
if (!params.content || params.content.length < 48 || params.content.length > 600) return { allowed: false, reason: "memory_revision_content_must_be_48_to_600_chars" };
|
|
644
|
+
if (text.length > 1400) return { allowed: false, reason: "memory_revision_payload_too_large" };
|
|
645
|
+
if (containsRawRuntimeNoise(text)) return { allowed: false, reason: "memory_revision_raw_runtime_noise" };
|
|
646
|
+
return { allowed: true };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function containsRawRuntimeNoise(text: string): boolean {
|
|
650
|
+
return /\b(stdout|stderr|traceback|stack trace|returncode|subprocess|os\.environ|sys\.exit|TimeoutExpired|print\(|cmd\s*=)\b/i.test(text);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function artifactToolResult(text: string, details: unknown) {
|
|
654
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function guardTool(base: ToolDefinition<any, any, any>, toolName: string, policy: ChildToolPolicy): ToolDefinition<any, any, any> {
|
|
658
|
+
return {
|
|
659
|
+
...base,
|
|
660
|
+
description: `${base.description} Guarded by pi-chalin child policy: bounded calls, no script-driven inspection, and surgical writes only.`,
|
|
661
|
+
promptGuidelines: [
|
|
662
|
+
...(base.promptGuidelines ?? []),
|
|
663
|
+
"Stay within the pi-chalin child tool budget.",
|
|
664
|
+
"Prefer targeted inspection over broad crawls.",
|
|
665
|
+
],
|
|
666
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
667
|
+
const input = isRecord(params) ? params : {};
|
|
668
|
+
const gate = policy.beforeTool(toolName, input);
|
|
669
|
+
if (!gate.allowed) return blockedToolResult(gate.reason);
|
|
670
|
+
const result = await base.execute(toolCallId, params as never, signal, onUpdate, ctx);
|
|
671
|
+
return policy.afterTool(toolName, result) as Awaited<ReturnType<typeof base.execute>>;
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function blockedToolResult(reason: string) {
|
|
677
|
+
return {
|
|
678
|
+
content: [{ type: "text" as const, text: `Blocked by pi-chalin child policy: ${reason}\nStop if you have enough evidence; otherwise use fewer, more targeted Pi-native tools.` }],
|
|
679
|
+
details: { blocked: true, reason },
|
|
680
|
+
isError: true,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function classifyBashCommand(command: string): { allowed: boolean; reason?: string } {
|
|
685
|
+
const normalized = command.trim();
|
|
686
|
+
if (!normalized) return { allowed: false, reason: "empty command" };
|
|
687
|
+
if (/[;&|`$<>]/.test(normalized) || normalized.includes("$(")) return { allowed: false, reason: "shell composition, substitution, pipes, or redirection are not allowed for child agents" };
|
|
688
|
+
if (/\b(?:python|python3|node|ruby|perl|php|deno|tsx|ts-node|sh|bash|zsh)\b/i.test(normalized)) return { allowed: false, reason: "creating or running ad-hoc scripts is not allowed" };
|
|
689
|
+
if (/\b(?:tee|touch|mv|cp|rm|mkdir|rmdir|chmod|chown|sed\s+-i|truncate)\b/i.test(normalized)) return { allowed: false, reason: "file mutation through bash is not allowed" };
|
|
690
|
+
|
|
691
|
+
const words = normalized.split(/\s+/);
|
|
692
|
+
const first = words[0] ?? "";
|
|
693
|
+
if (["pwd", "ls"].includes(first)) return { allowed: true };
|
|
694
|
+
if (["grep", "rg", "find"].includes(first)) return { allowed: true };
|
|
695
|
+
if (first === "cat") return words.length <= 4 ? { allowed: true } : { allowed: false, reason: "cat is allowed only for one small explicit file; prefer read" };
|
|
696
|
+
if (first === "git" && isAllowedGit(words.slice(1))) return { allowed: true };
|
|
697
|
+
if (isAllowedTestCommand(words)) return { allowed: true };
|
|
698
|
+
return { allowed: false, reason: `command '${first}' is not in the child-agent bash allowlist` };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function isAllowedGit(args: string[]): boolean {
|
|
702
|
+
const sub = args[0];
|
|
703
|
+
return Boolean(sub && ["status", "branch", "log", "diff", "show", "rev-parse", "rev-list"].includes(sub));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function isAllowedTestCommand(words: string[]): boolean {
|
|
707
|
+
const [cmd, ...args] = words;
|
|
708
|
+
const joined = words.join(" ");
|
|
709
|
+
if (cmd === "go") return args[0] === "test";
|
|
710
|
+
if (cmd === "cargo") return args[0] === "test" || args[0] === "check";
|
|
711
|
+
if (cmd === "pytest") return true;
|
|
712
|
+
if (cmd === "mvn") return args.includes("test");
|
|
713
|
+
if (cmd === "gradle") return args.includes("test");
|
|
714
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(cmd ?? "")) return /\b(test|vitest|jest|typecheck|lint)\b/.test(joined);
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function getPathParam(params: Record<string, unknown>): string | undefined {
|
|
719
|
+
return typeof params.path === "string" ? params.path : typeof params.file_path === "string" ? params.file_path : undefined;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function resolveProjectPath(target: string, cwd: string): string {
|
|
723
|
+
return path.isAbsolute(target) ? target : path.resolve(cwd, target);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function normalizeMetricPath(target: string, cwd: string): string {
|
|
727
|
+
const resolved = resolveProjectPath(target, cwd);
|
|
728
|
+
const relative = path.relative(cwd, resolved);
|
|
729
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return resolved;
|
|
730
|
+
return relative.split(path.sep).join("/");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function stringSize(value: unknown): number {
|
|
734
|
+
return typeof value === "string" ? value.length : 0;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function truncateForTool(text: string, maxChars: number): string {
|
|
738
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
739
|
+
if (normalized.length <= maxChars) return normalized;
|
|
740
|
+
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
744
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function runGuardedCommand(command: string, cwd: string, timeoutSeconds: number, signal?: AbortSignal): Promise<{ text: string; exitCode: number | null }> {
|
|
748
|
+
return new Promise((resolve, reject) => {
|
|
749
|
+
const child = spawn(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
750
|
+
let output = "";
|
|
751
|
+
const timeout = setTimeout(() => child.kill("SIGTERM"), timeoutSeconds * 1000);
|
|
752
|
+
const onAbort = () => child.kill("SIGTERM");
|
|
753
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
754
|
+
child.stdout?.on("data", (chunk) => { output += chunk.toString(); });
|
|
755
|
+
child.stderr?.on("data", (chunk) => { output += chunk.toString(); });
|
|
756
|
+
child.on("error", reject);
|
|
757
|
+
child.on("close", (exitCode) => {
|
|
758
|
+
clearTimeout(timeout);
|
|
759
|
+
signal?.removeEventListener("abort", onAbort);
|
|
760
|
+
const text = output.trim();
|
|
761
|
+
resolve({ exitCode, text: compressTextTail(text, 8000, "pi-chalin guarded bash") });
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function compressToolResult(result: unknown, toolName: string, maxChars: number): { result: unknown; outputChars: number; truncated: boolean } {
|
|
767
|
+
if (!isRecord(result) || !Array.isArray(result.content)) return { result, outputChars: 0, truncated: false };
|
|
768
|
+
const perToolMax = toolName === "read" ? Math.min(maxChars, 6000)
|
|
769
|
+
: toolName === "grep" || toolName === "find" ? Math.min(maxChars, 5000)
|
|
770
|
+
: toolName === "chalin_web_search" ? Math.min(maxChars, 7000)
|
|
771
|
+
: maxChars;
|
|
772
|
+
let outputChars = 0;
|
|
773
|
+
let truncated = false;
|
|
774
|
+
const content = result.content.map((part) => {
|
|
775
|
+
if (!isRecord(part) || typeof part.text !== "string") return part;
|
|
776
|
+
const compressed = compressTextTail(part.text, perToolMax, `${toolName} output`);
|
|
777
|
+
outputChars += compressed.length;
|
|
778
|
+
if (compressed !== part.text) truncated = true;
|
|
779
|
+
return { ...part, text: compressed };
|
|
780
|
+
});
|
|
781
|
+
const details = isRecord(result.details) ? result.details : {};
|
|
782
|
+
return { result: { ...result, content, details: { ...details, piChalinCompressed: truncated } }, outputChars, truncated };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function compressTextTail(text: string, maxChars: number, label: string): string {
|
|
786
|
+
if (text.length <= maxChars) return text;
|
|
787
|
+
const headSize = Math.floor(maxChars * 0.35);
|
|
788
|
+
const tailSize = Math.floor(maxChars * 0.55);
|
|
789
|
+
return [
|
|
790
|
+
text.slice(0, headSize).trimEnd(),
|
|
791
|
+
"",
|
|
792
|
+
`[${label} compressed by pi-chalin: ${text.length} chars → ${maxChars} chars; middle omitted]`,
|
|
793
|
+
"",
|
|
794
|
+
text.slice(-tailSize).trimStart(),
|
|
795
|
+
].join("\n");
|
|
796
|
+
}
|