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,106 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentDefinition, AgentThinkingLevel, ModelResolutionAttempt, ModelResolutionLog } from "./schemas.ts";
3
+
4
+ interface ModelResolutionContext {
5
+ cwd?: string;
6
+ agents?: Map<string, AgentDefinition>;
7
+ modelOverrides?: Record<string, string>;
8
+ thinkingOverrides?: Record<string, AgentThinkingLevel>;
9
+ extensionContext?: ExtensionContext;
10
+ }
11
+
12
+ export function resolveAgentModel(agent: AgentDefinition | undefined, agentName: string, context: ModelResolutionContext): { model: ExtensionContext["model"]; label: string; resolution: ModelResolutionLog; warnings: string[] } {
13
+ const fallback = context.extensionContext?.model;
14
+ const tier = agentTier(agentName);
15
+ const candidates: Array<{ source: ModelResolutionAttempt["source"]; ref?: string }> = [
16
+ { source: "session-override", ref: context.modelOverrides?.[`${agent?.scope ?? "built-in"}/${agentName}`] ?? context.modelOverrides?.[agentName] },
17
+ { source: "agent", ref: agent?.model && agent.model !== "inherit" ? agent.model : undefined },
18
+ { source: "tier", ref: context.modelOverrides?.[`tier/${tier}`] ?? process.env[`PI_CHALIN_${tier.toUpperCase()}_MODEL`] },
19
+ ];
20
+ const attempts: ModelResolutionAttempt[] = [];
21
+
22
+ for (const candidate of candidates) {
23
+ if (!candidate.ref) continue;
24
+ const resolved = resolveModelRef(candidate.ref, context);
25
+ attempts.push({ source: candidate.source, ref: candidate.ref, status: resolved.status, model: resolved.model ? `${resolved.model.provider}/${resolved.model.id}` : undefined, reason: resolved.reason });
26
+ if (resolved.status === "selected" && resolved.model) {
27
+ const selected = `${resolved.model.provider}/${resolved.model.id}`;
28
+ return {
29
+ model: resolved.model,
30
+ label: selected,
31
+ resolution: { selected, tier, attempts },
32
+ warnings: fallbackWarnings(agentName, attempts, selected),
33
+ };
34
+ }
35
+ }
36
+
37
+ const inherited = fallback ? `${fallback.provider}/${fallback.id}` : "inherit";
38
+ attempts.push({ source: "inherit", status: fallback ? "selected" : "fallback", model: inherited, reason: fallback ? undefined : "no active Pi model available" });
39
+ return {
40
+ model: fallback,
41
+ label: fallback ? `${inherited} (${tier}:inherit)` : `inherit (${tier})`,
42
+ resolution: { selected: inherited, tier, attempts },
43
+ warnings: fallbackWarnings(agentName, attempts, inherited),
44
+ };
45
+ }
46
+
47
+ export function resolveAgentThinking(
48
+ agent: AgentDefinition | undefined,
49
+ agentName: string,
50
+ context: ModelResolutionContext,
51
+ modelResolution?: ModelResolutionLog,
52
+ ): { level?: Exclude<AgentThinkingLevel, "inherit">; label: AgentThinkingLevel } {
53
+ const explicit = context.thinkingOverrides?.[`${agent?.scope ?? "built-in"}/${agentName}`] ?? context.thinkingOverrides?.[agentName];
54
+ const frontmatter = agent?.thinking && agent.thinking !== "inherit" ? agent.thinking : undefined;
55
+ const modelSuffix = selectedThinkingSuffix(modelResolution);
56
+ const level = explicit && explicit !== "inherit" ? explicit : frontmatter ?? modelSuffix;
57
+ return level ? { level, label: level } : { label: "inherit" };
58
+ }
59
+
60
+ function selectedThinkingSuffix(modelResolution?: ModelResolutionLog): Exclude<AgentThinkingLevel, "inherit"> | undefined {
61
+ const selected = modelResolution?.attempts.find((attempt) => attempt.status === "selected" && attempt.ref)?.ref;
62
+ if (!selected) return undefined;
63
+ return splitThinkingSuffix(selected).thinking;
64
+ }
65
+
66
+ function resolveModelRef(ref: string, context: ModelResolutionContext): { status: ModelResolutionAttempt["status"]; model?: ExtensionContext["model"]; reason?: string } {
67
+ const parsed = parseModelRef(splitThinkingSuffix(ref).model);
68
+ if (!parsed) return { status: "invalid", reason: "expected provider/model-id" };
69
+ const registry = context.extensionContext?.modelRegistry;
70
+ const model = registry?.find(parsed.provider, parsed.modelId);
71
+ if (!model) return { status: "unavailable", reason: "not found in Pi model registry" };
72
+ if (!registry?.hasConfiguredAuth(model)) return { status: "unauthenticated", model, reason: "provider is not configured" };
73
+ return { status: "selected", model };
74
+ }
75
+
76
+ function splitThinkingSuffix(ref: string): { model: string; thinking?: Exclude<AgentThinkingLevel, "inherit"> } {
77
+ const trimmed = ref.trim();
78
+ const colon = trimmed.lastIndexOf(":");
79
+ if (colon === -1) return { model: trimmed };
80
+ const suffix = trimmed.slice(colon + 1);
81
+ if (suffix === "off" || suffix === "minimal" || suffix === "low" || suffix === "medium" || suffix === "high" || suffix === "xhigh") {
82
+ return { model: trimmed.slice(0, colon), thinking: suffix };
83
+ }
84
+ return { model: trimmed };
85
+ }
86
+
87
+ function parseModelRef(ref: string): { provider: string; modelId: string } | undefined {
88
+ const trimmed = ref.trim();
89
+ if (!trimmed || trimmed === "inherit") return undefined;
90
+ const slash = trimmed.indexOf("/");
91
+ if (slash <= 0 || slash === trimmed.length - 1) return undefined;
92
+ return { provider: trimmed.slice(0, slash), modelId: trimmed.slice(slash + 1) };
93
+ }
94
+
95
+ function agentTier(agentName: string): "fast" | "balanced" | "strong" {
96
+ if (["scout", "context-builder", "delegate"].includes(agentName)) return "fast";
97
+ if (["worker", "oracle"].includes(agentName)) return "strong";
98
+ return "balanced";
99
+ }
100
+
101
+ function fallbackWarnings(agentName: string, attempts: ModelResolutionAttempt[], selected: string): string[] {
102
+ const failed = attempts.filter((attempt) => ["invalid", "unavailable", "unauthenticated", "fallback"].includes(attempt.status) && attempt.source !== "inherit");
103
+ if (failed.length === 0) return [];
104
+ const refs = failed.map((attempt) => `${attempt.ref ?? attempt.source} ${attempt.status}`).join("; ");
105
+ return [`Model fallback for ${agentName}: ${refs}; selected ${selected}.`];
106
+ }
@@ -0,0 +1,99 @@
1
+ import type { AgentDefinition } from "./schemas.ts";
2
+
3
+ export function buildCompactChalinOrchestratorSystemPrompt(): string {
4
+ return [
5
+ "## pi-chalin orchestration (compact)",
6
+ "You are the primary Pi agent. Choose direct native execution for safe bounded implementation/scaffolding/test/refactor tasks with explicit files or acceptance criteria. Use pi-chalin only when specialist context isolation is worth the latency.",
7
+ "Direct bounded code work: do not narrate planning or emit visible analysis before tools; first inspect briefly, then write the requested implementation/test/docs promptly, ensure requested tests prove the behavior with non-trivial assertions rather than only starter smoke/empty coverage, run the nearest relevant verification command, fix failures, rerun verification after the final edit, and answer immediately with changed paths, verification result, and one note about the satisfied constraint. For timer behavior, prefer injected clocks/schedulers over brittle mock timer APIs; for Bun CLI subprocess tests, preserve process.env and derive paths directly from import.meta.url.",
8
+ "If the prompt says review-only, docs-only, no code changes, or no mutations, obey that literally: do not add tests/source files or modify code unless explicitly requested. For dependency-free TypeScript scaffolding, use exact requested paths, export the requested API, use package.json test script `bun test`, keep tests under `test/`, avoid uninstalled runners/dependencies, declare package.json `bin` when scaffolding a CLI command; `bin` values must be executable file paths, not command strings, and verify before final answer.",
9
+ "Call `chalin_route` for broad/deep project analysis, architecture/migration strategy, broad review, complex/risky multi-file work, risky long-file/surgical edits, parallel option comparison, or memory/continuation work. Call `chalin_interview` only when a real decision is ambiguous.",
10
+ "If chalin_route returns, answer from its Final answer material immediately. Do not call chalin_route for bounded direct work merely because pi-chalin is available.",
11
+ ].join("\n");
12
+ }
13
+
14
+ export function buildCompactChalinCriticalSystemPrompt(): string {
15
+ return [
16
+ "## pi-chalin orchestration (critical compact)",
17
+ "This prompt requires pi-chalin before native tools. First action should be one `chalin_route` call; do not inspect files directly before routing.",
18
+ "Use `chain` scout → planner → worker → reviewer for risky implementation, long-file/surgical edits, auth/security-sensitive mutations, or complex multi-file work.",
19
+ "Use `dag` for independent implementation slices: discovery/planning first, parallel workers with non-overlapping ownership, then reviewer synthesis.",
20
+ "For surgical/long-file edits: worker must use targeted edit discipline, not full rewrite; reviewer must verify scope and tests.",
21
+ "After `chalin_route` returns, answer from Final answer material immediately. In non-interactive runs, the chalin result itself is the handoff; do not call native tools after chalin unless the tool result explicitly says a critical blocker remains.",
22
+ "Available agents: scout, planner, worker, reviewer, context-builder, researcher, oracle.",
23
+ ].join("\n");
24
+ }
25
+
26
+ export function buildCompactChalinResumeSystemPrompt(): string {
27
+ return [
28
+ "## pi-chalin resume orchestration (compact)",
29
+ "The user is continuing an interrupted pi-chalin workflow. First action must be `chalin_resume`; do not answer from partial findings.",
30
+ "Spanish continuation prompts such as `continua`, `continúa`, `sigue`, `reanuda`, or `retoma` mean the same thing when a resumable pi-chalin run exists.",
31
+ "Do not call `chalin_route` for the same work. Resume the persisted run, preserve completed handoffs, and continue only pending/stale subagent steps.",
32
+ "After `chalin_resume` returns, answer from its Final answer material immediately.",
33
+ ].join("\n");
34
+ }
35
+
36
+ export function buildChalinOrchestratorSystemPrompt(agents: AgentDefinition[]): string {
37
+ const roster = agents.map(formatAgentForPrompt).join("\n") || "- none";
38
+ return [
39
+ "## pi-chalin orchestration",
40
+ "You are the primary Pi agent. Decide whether to answer directly, call `chalin_interview`, or call `chalin_route` as an agents-as-tools runtime.",
41
+ "pi-chalin is optional orchestration for work that benefits from specialist context isolation; the user does not need to invoke it.",
42
+ "",
43
+ "### Gate",
44
+ "Before read/bash/grep/find/ls, decide whether this is repository orchestration work.",
45
+ "MUST call `chalin_resume` first when the user asks to continue/resume/continua/continúa/sigue/reanuda/retoma and a prior pi-chalin run was paused, interrupted, or left stale by terminal shutdown. Do not answer from partial findings until resume has no resumable run.",
46
+ "MUST call `chalin_interview` before `chalin_route` when the request is ambiguous, uses a term you cannot resolve from memory/codebase exploration, has missing scope/constraints, or contains an uncovered decision branch that would make subagents guess.",
47
+ "MUST call `chalin_route` first for clear current branch/diff/PR summaries, what this project does, project structure, architecture/migration/project-wide refactor strategy, broad/project-wide review, security/correctness review over a broad surface, complex/risky multi-file implementation, risky long-file/surgical edits, or prior memory. Do NOT treat an explicit named-file refactor implementation as refactor strategy; that is bounded direct work unless the prompt says broad/risky/long-file.",
48
+ "Hard direct gate: if the user names one to three target file paths and asks to refactor/fix/add/update/extract tests or helpers, do NOT call `chalin_route`; use native read/edit/write/bash directly. Calling chalin_route for that bounded case wastes latency and will be redirected back to direct execution.",
49
+ "Hard direct gate: if the user asks for a bounded read-only mini-project review and explicitly says not to modify files, do NOT call `chalin_route`; inspect the small file set directly, answer with concrete path evidence, and perform no writes.",
50
+ "Single-file is NOT automatically direct: if the user says the file is long, asks for a surgical/targeted behavior/auth validation change, or warns not to rewrite the whole file, use chalin_route with worker/reviewer discipline.",
51
+ "You choose topology, agents, tasks, risk, memory use, interviews, and plan size. The code does not classify prompts for you.",
52
+ "Call `chalin_interview` in batches of 1-5 concise questions with at most 5 concise answers each; mark the best answer as recommended and allow custom answers unless safety requires constrained choices.",
53
+ "After `chalin_interview` returns, use its artifact answers as context. If still blocked, ask another interview batch; if ready, continue planning or call `chalin_route`.",
54
+ "Call `chalin_route` at most once per user prompt after the needed interview context is available. After it returns, immediately write the final answer from its `Final answer material`; do not keep thinking, do not call another tool, and do not inspect files unless the handoff names a concrete blocking gap.",
55
+ "For long-running/continuation work, use `chalin_resume` for interrupted runs; use `chalin_artifact_resume` when the user names an existing feature/task artifact; otherwise set `needsArtifacts: true` so pi-chalin records run summaries and handoffs.",
56
+ "",
57
+ "### Interview when",
58
+ "Use `chalin_interview` when proceeding would require guessing user intent, unknown terminology, risk tolerance, target scope, accepted tradeoffs, or destructive/large-change boundaries.",
59
+ "Do not interview for information that can be cheaply and safely discovered from the local codebase or existing pi-chalin memory; discover first, interview only for the remaining blocker.",
60
+ "Persisted interview answers are artifacts and should be reused by the next route/subagents instead of asking again.",
61
+ "",
62
+ "### Direct answer when",
63
+ "Use normal Pi for greetings, short clarifications, simple definitions without local inspection, one obvious command, one tiny isolated edit, bounded read-only mini-project reviews, explicit named-file bugfixes/refactors with tests, or bounded implementation/scaffolding with a clear file list and low risk. Direct execution still means full fidelity to every explicit acceptance criterion: if the user asks to extract helpers, add tests, preserve behavior, or avoid dependencies, do exactly that before final answer. If the user asks for tests, changing only implementation is incomplete even when existing tests pass; add or update the relevant test file before final verification. The test change must be behavior-bearing: assert requested outputs/effects and relevant edge/failure cases when applicable; merely preserving or renaming a starter smoke/empty test is incomplete. For time/window behavior, make tests deterministic with an injected or controlled clock when possible; avoid brittle mock timer APIs unless you verify the current Bun API in this project. Do not assert exact `Date.now()`-derived milliseconds against real wall time. For named-file bugfixes/refactors, inspect the target file and tests once, edit promptly, then verify. For dependency-free TypeScript scaffolding, write the exact requested files, keep requested APIs/exported helpers in the requested source file, prefer `bun test`, use `test/` unless the user explicitly asks otherwise, avoid uninstalled runners (`tsx`, `vitest`, `jest`), export requested APIs, declare requested package.json `bin` entries that point to executable file paths, never command strings, and fix verification failures and rerun verification after the final edit before final answer. For Bun CLI subprocess tests, preserve `process.env` and derive target paths directly from `import.meta.url`; do not strip PATH/NODE_OPTIONS or compute parent directories twice. After successful edits plus a passing verification command after the final edit, stop and answer immediately with changed files, verification result, and one note naming the requested behavior/constraint satisfied; do not keep exploring or run unrelated checks. Do NOT treat long-file, auth/validation mutation, broad behavior, or no-rewrite edits as tiny.",
64
+ "",
65
+ "### Default recipes",
66
+ "- Branch/diff/project understanding: `chain` with scout → context-builder; context-builder should synthesize scout handoff without extra file reads unless exact line-level behavior is explicitly required.",
67
+ "- Broad/deep project analysis MUST optimize for accuracy, not brevity. Require a Coverage Matrix and Evidence Table before synthesis; every critical surface must be marked covered with evidence, not present with evidence, or unknown/gap.",
68
+ "- If the user asks what a project does in depth but does not explicitly request folder-by-folder fan-out, prefer `chain` with scout → context-builder using `budget: \"deep\"`; it is usually more accurate and faster than a large DAG.",
69
+ "- Reserve `dag` for explicit staged fan-out/fan-in requests, e.g. the user asks to divide analysis by many folders/modules or the repo is clearly too large for one context-builder. Use scout first, parallel context-builder/reviewer/researcher tasks per independent area, then a final reviewer/context-builder synthesis stage. Give deep-analysis children `budget: \"deep\"` and ask them to preserve coverage/evidence, not just compact notes.",
70
+ "- For memory/agent/orchestration repos, include domain-critical surfaces in the route tasks: local/project detection, memory persistence and sync, MCP/tool surface, HTTP/API routes, conflict surfacing, external integrations, UI/dashboard/cloud, and test/eval status.",
71
+ "- Architecture/migration/project-wide refactor strategy: `chain` with scout → planner → reviewer. Explicit named-file refactor implementation is not this category; keep it direct when low-risk and bounded.",
72
+ "- Project-wide review: `chain` with scout → reviewer.",
73
+ "- Complex/risky multi-file implementation or requests that need broad discovery before code changes: use `chain` with scout → planner → worker → reviewer. Bounded scaffolding/greenfield/refactor/bugfix tasks with explicit files and simple acceptance criteria may stay direct to avoid orchestration overhead. In non-interactive print mode, do not convert safe bounded edits into dry-run reports; either edit directly or run a real chalin_route. When staying direct, satisfy each requested code-shape constraint, not just behavior. For TypeScript scaffolds in empty repos, use dependency-free Bun test infrastructure, exact requested paths, and exports in the requested source file instead of inventing external runners, alternate test folders, or moving the API elsewhere.",
74
+ "- Risky long-file or surgical behavior edits, including prompts like 'archivo largo', 'validacion puntual', 'avoid rewrite', or 'no reescribir': MUST use `chain` with scout → planner → worker → reviewer and tell worker to use targeted edit, not full rewrite.",
75
+ "- Alternative comparison with two approaches/options: MUST use plain `parallel` independent planners/reviewers, not `dag`, unless implementation or staged discovery is explicitly required.",
76
+ "- Independent implementation slices: MUST include worker agents. Prefer `dag` with discovery/planning first, a fan-out stage of parallel worker tasks with explicit non-overlapping ownership scopes, then reviewer synthesis. Use plain `parallel` only for pure worker fan-out that needs no prior discovery/review stage.",
77
+ "- Use `dag` when the workflow needs staged fan-out/fan-in, e.g. scout → parallel folder agents → reviewer synthesis.",
78
+ "- Explicit recall: `memory-only`.",
79
+ "- Long autonomous tasks: set `needsArtifacts: true`; subagents should leave compact handoffs, memory candidates, and validation contracts when relevant.",
80
+ "- Tool budgets: each step may set `budget` to `tight`, `normal`, `deep`, or `extended`. Use `deep` for project-wide/folder fan-out analysis. Use `extended` only for a bounded long-running stage with artifacts/checkpoints; otherwise split the work into a DAG instead of inflating one child.",
81
+ "",
82
+ "### Cost, risk, and latency",
83
+ "- Prefer 1 chalin call, 1-3 agents, and short handoffs over repeated orchestration loops.",
84
+ "- Use `low` risk for read-only analysis/planning. Use `medium+` only for mutation, approvals, secrets, destructive actions, or security-sensitive execution.",
85
+ "- Subagents should produce compact handoffs; you own the final answer.",
86
+ "- After a successful chalin run, answer from the handoff in the user language. If `Final answer material` is present, treat it as sufficient and respond now.",
87
+ "- Use `chalin_web_search` only for current external facts, docs, URLs, or explicit web research. It is available globally and to external-context agents; workers should not use it by default.",
88
+ "- Do not send worker agents to browse the web by default.",
89
+ "",
90
+ "### Available pi-chalin agents",
91
+ roster,
92
+ ].join("\n");
93
+ }
94
+
95
+ function formatAgentForPrompt(agent: AgentDefinition): string {
96
+ const tools = agent.tools.length > 0 ? agent.tools.join(", ") : "none";
97
+ const capabilities = agent.capabilities.length > 0 ? agent.capabilities.join(", ") : "none";
98
+ return `- ${agent.name}: ${agent.description} concern=${agent.concern} thinking=${agent.thinking ?? "inherit"} capabilities=${capabilities} tools=${tools}`;
99
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,50 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export interface ChalinPathsOptions {
6
+ cwd: string;
7
+ userRoot?: string;
8
+ packageRoot?: string;
9
+ }
10
+
11
+ export interface ChalinPaths {
12
+ cwd: string;
13
+ packageRoot: string;
14
+ builtInAgentsDir: string;
15
+ projectRoot: string;
16
+ projectConfigPath: string;
17
+ projectAgentsDir: string;
18
+ userRoot: string;
19
+ userConfigPath: string;
20
+ userAgentsDir: string;
21
+ }
22
+
23
+ export function packageRootFromImportMeta(importMetaUrl = import.meta.url): string {
24
+ return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..");
25
+ }
26
+
27
+ export function resolveChalinPaths(options: ChalinPathsOptions): ChalinPaths {
28
+ const cwd = path.resolve(options.cwd);
29
+ const packageRoot = options.packageRoot ? path.resolve(options.packageRoot) : packageRootFromImportMeta();
30
+ const projectRoot = cwd;
31
+ const userRoot = options.userRoot ? path.resolve(options.userRoot) : path.join(os.homedir(), ".pi", "chalin");
32
+
33
+ return {
34
+ cwd,
35
+ packageRoot,
36
+ builtInAgentsDir: path.join(packageRoot, "agents"),
37
+ projectRoot,
38
+ projectConfigPath: path.join(projectRoot, ".pi-chalin", "config.json"),
39
+ projectAgentsDir: path.join(projectRoot, ".pi-chalin", "agents"),
40
+ userRoot,
41
+ userConfigPath: path.join(userRoot, "config.json"),
42
+ userAgentsDir: path.join(userRoot, "agents"),
43
+ };
44
+ }
45
+
46
+ export function expandTilde(input: string): string {
47
+ if (input === "~") return os.homedir();
48
+ if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
49
+ return input;
50
+ }
@@ -0,0 +1,149 @@
1
+ import type { ChalinHandleResult } from "./kernel.ts";
2
+ import type { ChalinRouteOutcome } from "./runtime-state.ts";
3
+ import type { RouteDecision, RunState } from "./schemas.ts";
4
+
5
+ export function formatRoute(route: RouteDecision, result: ChalinHandleResult | undefined, options: { availableAgents?: string[] } = {}): string {
6
+ if (!result) {
7
+ return [
8
+ `Chalin workflow: ${route.kind}`,
9
+ `Agents: ${route.agents.join(" → ") || "none"}`,
10
+ `Risk: ${route.risk}`,
11
+ `Reason: ${route.reason}`,
12
+ options.availableAgents ? `\nAvailable agents: ${options.availableAgents.join(", ") || "none"}` : undefined,
13
+ ].filter((line): line is string => line !== undefined).join("\n");
14
+ }
15
+
16
+ const finalMaterial = finalAnswerMaterial(result.run);
17
+ const memoryMaterial = !finalMaterial && result.memories.length > 0 ? formatMemoryMaterial(result.memories) : undefined;
18
+ const supportingFindings = supportingAgentFindings(result.run);
19
+ const lines = [
20
+ `pi-chalin completed: ${route.agents.join(" → ") || route.kind}`,
21
+ `status: ${result.run?.status ?? result.approval.action}`,
22
+ result.approval.action === "allow"
23
+ ? "Instruction for the primary Pi agent: answer the user now from the Final answer material below. Do not call more tools unless it explicitly says a critical gap remains."
24
+ : "Instruction for the primary Pi agent: pi-chalin did not execute because approval is required. Do not claim completion. If this is a safe explicit user-requested edit, continue directly with native tools; otherwise explain that approval is required.",
25
+ result.approval.action !== "allow" ? `Approval: ${result.approval.action} — ${result.approval.reason}` : undefined,
26
+ result.memories.length > 0 ? `Memory used: ${result.memories.length}` : undefined,
27
+ finalMaterial ? "\nFinal answer material:" : undefined,
28
+ finalMaterial,
29
+ memoryMaterial ? "\nMemory material:" : undefined,
30
+ memoryMaterial,
31
+ supportingFindings ? "\nSupporting findings:" : undefined,
32
+ supportingFindings,
33
+ !finalMaterial && result.run ? "\nSubagent handoff:" : undefined,
34
+ !finalMaterial && result.run ? result.run.steps.map(formatStep).join("\n") : undefined,
35
+ options.availableAgents ? `\nAvailable agents: ${options.availableAgents.join(", ") || "none"}` : undefined,
36
+ ];
37
+ return lines.filter((line): line is string => line !== undefined && line.length > 0).join("\n");
38
+ }
39
+
40
+ function formatMemoryMaterial(memories: ChalinHandleResult["memories"]): string {
41
+ return memories
42
+ .slice(0, 8)
43
+ .map((memory) => `- ${memory.category} · ${memory.sourceAgent} · ${memory.content}`)
44
+ .join("\n");
45
+ }
46
+
47
+ export function finalAnswerMaterial(run: RunState | undefined): string | undefined {
48
+ if (!run) return undefined;
49
+ const completeSteps = run.steps.filter((step) => isUsableStepStatus(step.status));
50
+ if (shouldAggregateFinalMaterial(run, completeSteps)) {
51
+ const material = completeSteps
52
+ .map((step) => {
53
+ const output = stepFullOutput(step);
54
+ return output ? `## ${step.agent}\n${output}` : undefined;
55
+ })
56
+ .filter((item): item is string => Boolean(item))
57
+ .join("\n\n");
58
+ return material ? truncate(material, finalAnswerMaterialBudget(run)) : undefined;
59
+ }
60
+ const primary = completeSteps.at(-1) ?? run.steps.at(-1);
61
+ const output = primary ? stepOutput(primary) : undefined;
62
+ return output ? truncate(output, finalAnswerMaterialBudget(run)) : undefined;
63
+ }
64
+
65
+ function shouldAggregateFinalMaterial(run: RunState, completeSteps: RunState["steps"]): boolean {
66
+ if (completeSteps.length <= 1) return false;
67
+ if (run.route.kind === "multi-agent-dag") return true;
68
+ return /\b(deep|in[- ]depth|profundidad|profundo|an[aá]lisis|project analysis|Coverage Matrix|Evidence Table)\b/i.test(run.route.reason);
69
+ }
70
+
71
+ function finalAnswerMaterialBudget(run: RunState): number {
72
+ const parsed = Number(process.env.PI_CHALIN_FINAL_MATERIAL_CHARS);
73
+ if (Number.isFinite(parsed) && parsed > 500) return Math.floor(parsed);
74
+ if (run.route.kind === "multi-agent-dag") return 12000;
75
+ if (/\b(deep|in[- ]depth|profundidad|profundo|an[aá]lisis|project analysis|Coverage Matrix|Evidence Table)\b/i.test(run.route.reason)) return 10000;
76
+ return 1200;
77
+ }
78
+
79
+ function supportingAgentFindings(run: RunState | undefined): string | undefined {
80
+ if (!run) return undefined;
81
+ const completeSteps = run.steps.filter((step) => isUsableStepStatus(step.status));
82
+ if (completeSteps.length <= 1) return undefined;
83
+ return completeSteps
84
+ .slice(0, -1)
85
+ .map((step) => `- ${step.agent}: ${truncate(stepOutput(step) || "no output", 260)}`)
86
+ .join("\n");
87
+ }
88
+
89
+ function stepOutput(step: RunState["steps"][number]): string | undefined {
90
+ return step.output?.handoff || step.output?.text || step.output?.raw || step.error;
91
+ }
92
+
93
+ function stepFullOutput(step: RunState["steps"][number]): string | undefined {
94
+ return step.output?.text || step.output?.raw || step.output?.handoff || step.error;
95
+ }
96
+
97
+ export function outcomeForResult(result: ChalinHandleResult): ChalinRouteOutcome {
98
+ if (result.approval.action === "ask") return "ask";
99
+ if (result.approval.action === "block") return "block";
100
+ if (result.run?.status === "failed") return "failed";
101
+ if (result.run?.status === "paused") return "paused";
102
+ return "complete";
103
+ }
104
+
105
+ export function formatDirectRecommendation(route: RouteDecision, reason: string): string {
106
+ return [
107
+ "pi-chalin direct execution recommended",
108
+ "status: direct-recommended",
109
+ reason,
110
+ `Original route: ${route.kind} · ${route.agents.join(" → ") || "none"}`,
111
+ "Instruction for the primary Pi agent: do not claim completion from this tool result. Continue now with native tools and complete the bounded edit directly.",
112
+ ].join("\n");
113
+ }
114
+
115
+ function formatStep(step: RunState["steps"][number]): string {
116
+ return `- ${step.agent}: ${truncate(stepOutput(step) || "no output", 420)}`;
117
+ }
118
+
119
+ export function compactRouteDetails(route: RouteDecision, result: ChalinHandleResult, diagnostics: unknown[]) {
120
+ return {
121
+ route,
122
+ approval: result.approval,
123
+ memoriesUsed: result.memories?.length ?? 0,
124
+ run: result.run ? {
125
+ id: result.run.id,
126
+ status: result.run.status,
127
+ logsPath: result.run.logsPath,
128
+ metrics: result.run.metrics,
129
+ steps: result.run.steps.map((step) => ({
130
+ agent: step.agent,
131
+ status: step.status,
132
+ model: step.model,
133
+ thinkingLevel: step.thinkingLevel,
134
+ error: step.error,
135
+ handoff: truncate(step.output?.handoff || step.output?.text || step.error || "", 600),
136
+ })),
137
+ } : undefined,
138
+ diagnostics,
139
+ };
140
+ }
141
+
142
+ function isUsableStepStatus(status: RunState["status"] | undefined): boolean {
143
+ return status === "complete" || status === "budget-capped";
144
+ }
145
+
146
+ function truncate(text: string, max: number): string {
147
+ const normalized = text.replace(/\s+/g, " ").trim();
148
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
149
+ }
@@ -0,0 +1,92 @@
1
+ import type { AgentStep, RouteDecision } from "./schemas.ts";
2
+
3
+ export function ensureMutationRouteHasWorker(route: RouteDecision, task: string): RouteDecision {
4
+ if (!taskExpectsWorkspaceMutation(task) || route.kind === "memory-only" || route.kind === "ask-user" || route.agents.includes("worker")) return route;
5
+ if (!route.plan) return route;
6
+
7
+ const workerStep: AgentStep = {
8
+ id: "implementation",
9
+ agent: "worker",
10
+ task: [
11
+ "Implement the user's requested workspace changes.",
12
+ "Preserve existing behavior, satisfy every explicit acceptance criterion, and run or update relevant tests when available.",
13
+ `Original task: ${task}`,
14
+ ].join(" "),
15
+ budget: "normal",
16
+ };
17
+ const reason = `${route.reason} Mutation task normalized by pi-chalin: added a worker step because implementation routes must include an executor.`;
18
+
19
+ if (route.plan.kind === "dag") {
20
+ return {
21
+ ...route,
22
+ agents: [...route.agents, "worker"],
23
+ needsArtifacts: true,
24
+ reason,
25
+ plan: {
26
+ kind: "dag",
27
+ stages: [...route.plan.stages, { id: "implementation", tasks: [workerStep] }],
28
+ },
29
+ };
30
+ }
31
+
32
+ const existingSteps = route.plan.kind === "single"
33
+ ? [{ id: "existing", agent: route.plan.agent, task: route.plan.task, budget: route.plan.budget }]
34
+ : route.plan.kind === "chain" ? route.plan.steps : route.plan.tasks;
35
+ const reviewerIndex = existingSteps.findIndex((step) => step.agent === "reviewer");
36
+ const steps = reviewerIndex >= 0
37
+ ? [...existingSteps.slice(0, reviewerIndex), workerStep, ...existingSteps.slice(reviewerIndex)]
38
+ : [...existingSteps, workerStep];
39
+ return {
40
+ ...route,
41
+ kind: "multi-agent-chain",
42
+ agents: steps.map((step) => step.agent),
43
+ needsArtifacts: true,
44
+ reason,
45
+ plan: { kind: "chain", steps },
46
+ };
47
+ }
48
+
49
+ function taskExpectsWorkspaceMutation(task: string): boolean {
50
+ return /\b(refactoriza|implementa|a[nñ]ade|a[nñ]adir|actualiza|modifica|corrige|arregla|crea|extrae|implement|add|update|modify|fix|create|write|edit|extract|scaffold)\b/i.test(task)
51
+ || /\brefactor\b/i.test(task) && /\b(src\/|test\/|archivo|file|\.tsx?|\.jsx?|\.py|\.go|\.rs)\b/i.test(task);
52
+ }
53
+
54
+ export function directExecutionRecommendation(task: string, route: RouteDecision): string | undefined {
55
+ if (route.kind === "memory-only" || route.kind === "ask-user") return undefined;
56
+ if (route.risk === "high" || route.risk === "critical") return undefined;
57
+ if (isBoundedReadOnlyReview(task)) {
58
+ return [
59
+ "Direct execution recommended: this is a bounded read-only review that explicitly forbids file changes.",
60
+ "Use native read/grep/find/ls tools only; inspect the small relevant file set directly, perform no writes, and answer with concrete path evidence.",
61
+ ].join(" ");
62
+ }
63
+ if (!taskExpectsWorkspaceMutation(task)) return undefined;
64
+ if (!hasExplicitFileTargets(task)) return undefined;
65
+ if (hasBroadOrRiskyScope(task)) return undefined;
66
+ return [
67
+ "Direct execution recommended: this is a bounded explicit-file mutation.",
68
+ "Use native read/edit/write/bash tools instead of subagents; inspect the named target file(s), make the requested change, run the nearest relevant verification command, fix failures and rerun after the final edit, then answer with paths plus passing verification status.",
69
+ ].join(" ");
70
+ }
71
+
72
+ function hasExplicitFileTargets(task: string): boolean {
73
+ const matches = task.match(/\b[\w@.-]+(?:\/[\w@.-]+)+\.[a-zA-Z0-9]+\b/g) ?? [];
74
+ return matches.length > 0 && new Set(matches).size <= 3;
75
+ }
76
+
77
+ function isBoundedReadOnlyReview(task: string): boolean {
78
+ if (taskExpectsWorkspaceMutation(task)) return false;
79
+ if (!/\b(revisa|review|audit|audita|inspect|inspecciona)\b/i.test(task)) return false;
80
+ if (!/\b(no modifiques|no modificar|no edits?|do not modify|don't modify|read[- ]only|solo lectura|sin modificar)\b/i.test(task)) return false;
81
+ if (hasBroadReadOnlyScope(task)) return false;
82
+ return /\b(mini|small|peque[nñ]o|bounded|concret[oa]s?|specific paths?|paths concretos|file evidence|evidencia)\b/i.test(task)
83
+ || hasExplicitFileTargets(task);
84
+ }
85
+
86
+ function hasBroadReadOnlyScope(task: string): boolean {
87
+ return /\b(project[- ]wide|entire project|whole project|all files|monorepo|architecture|migration|migraci[oó]n|deep|en profundidad|broad|amplio|large|complex|risky)\b/i.test(task);
88
+ }
89
+
90
+ function hasBroadOrRiskyScope(task: string): boolean {
91
+ return /\b(project[- ]wide|entire project|whole project|all files|monorepo|architecture|migration|migraci[oó]n|security|seguridad|auth|authentication|authorization|permissions?|database|schema|concurrency|race condition|large|complex|risky|long file|archivo largo|surgical|quir[uú]rgic|no rewrite|sin reescribir)\b/i.test(task);
92
+ }