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,202 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ArtifactStore, InterviewDecisionInput } from "./artifacts.ts";
3
+
4
+ export interface InterviewChoiceInput {
5
+ label: string;
6
+ value?: string;
7
+ recommended?: boolean;
8
+ }
9
+
10
+ export interface InterviewQuestionInput {
11
+ id?: string;
12
+ question: string;
13
+ choices: InterviewChoiceInput[];
14
+ allowCustom?: boolean;
15
+ }
16
+
17
+ export interface InterviewRequestInput {
18
+ featureId?: string;
19
+ task: string;
20
+ reason: string;
21
+ questions: InterviewQuestionInput[];
22
+ batchSize?: number;
23
+ }
24
+
25
+ export interface InterviewAnswer {
26
+ questionId: string;
27
+ question: string;
28
+ answer: string;
29
+ choiceLabel?: string;
30
+ custom: boolean;
31
+ recommended: boolean;
32
+ }
33
+
34
+ export interface InterviewResult {
35
+ featureId: string;
36
+ task: string;
37
+ reason: string;
38
+ status: "answered" | "cancelled" | "non-interactive";
39
+ answers: InterviewAnswer[];
40
+ artifactCheckpoint?: string;
41
+ }
42
+
43
+ const CUSTOM_OPTION = "Custom answer…";
44
+ const SKIP_OPTION = "Skip / not sure";
45
+
46
+ export async function runChalinInterview(
47
+ ctx: ExtensionContext,
48
+ store: ArtifactStore,
49
+ input: InterviewRequestInput,
50
+ ): Promise<InterviewResult> {
51
+ const featureId = safeFeatureId(input.featureId || input.task || input.reason || "interview");
52
+ const questions = normalizeQuestions(input.questions, input.batchSize ?? 5);
53
+ await store.initFeature({ featureId, goal: input.task, chain: ["interview"], currentStep: "Clarify request" });
54
+
55
+ if (questions.length === 0) {
56
+ const result: InterviewResult = { featureId, task: input.task, reason: input.reason, status: "cancelled", answers: [] };
57
+ await persistInterview(store, result);
58
+ return result;
59
+ }
60
+
61
+ if (!ctx.hasUI) {
62
+ const result: InterviewResult = { featureId, task: input.task, reason: input.reason, status: "non-interactive", answers: [] };
63
+ await persistInterview(store, result);
64
+ ctx.ui.notify(formatInterviewRequest(input.task, input.reason, questions), "warning");
65
+ return result;
66
+ }
67
+
68
+ const answers: InterviewAnswer[] = [];
69
+ for (let index = 0; index < questions.length; index += 1) {
70
+ const question = questions[index]!;
71
+ const selected = await ctx.ui.select(formatQuestionTitle(index + 1, questions.length, question.question), questionOptions(question));
72
+ if (!selected) break;
73
+ if (selected === SKIP_OPTION) {
74
+ answers.push({ questionId: question.id ?? `q${index + 1}`, question: question.question, answer: "Not sure", choiceLabel: selected, custom: false, recommended: false });
75
+ continue;
76
+ }
77
+ if (selected === CUSTOM_OPTION) {
78
+ const custom = await ctx.ui.input(compact(question.question, 72), "Type your answer...");
79
+ if (!custom?.trim()) continue;
80
+ answers.push({ questionId: question.id ?? `q${index + 1}`, question: question.question, answer: custom.trim(), custom: true, recommended: false });
81
+ continue;
82
+ }
83
+ const choice = question.choices.find((candidate) => formatChoice(candidate, recommendedChoice(question)) === selected);
84
+ const answer = choice?.value?.trim() || choice?.label.trim() || selected.replace(/\s*\(RECOMMENDED\)$/, "").trim();
85
+ answers.push({
86
+ questionId: question.id ?? `q${index + 1}`,
87
+ question: question.question,
88
+ answer,
89
+ choiceLabel: choice?.label,
90
+ custom: false,
91
+ recommended: Boolean(choice?.recommended),
92
+ });
93
+ }
94
+
95
+ const result: InterviewResult = {
96
+ featureId,
97
+ task: input.task,
98
+ reason: input.reason,
99
+ status: answers.length === questions.length ? "answered" : "cancelled",
100
+ answers,
101
+ };
102
+ const checkpoint = await persistInterview(store, result);
103
+ result.artifactCheckpoint = checkpoint.id;
104
+ return result;
105
+ }
106
+
107
+ export function formatInterviewResult(result: InterviewResult): string {
108
+ const lines = [
109
+ `pi-chalin interview · ${result.status}`,
110
+ `artifact: ${result.featureId}`,
111
+ `reason: ${compact(result.reason, 140)}`,
112
+ result.answers.length ? "answers:" : "answers: none",
113
+ ...result.answers.map((answer, index) => `${index + 1}. ${compact(answer.question, 90)} → ${compact(answer.answer, 120)}${answer.custom ? " (custom)" : answer.recommended ? " (recommended)" : ""}`),
114
+ result.status === "answered" ? "next: continue planning or call chalin_route with these answers as context." : "next: ask the user directly before running subagents.",
115
+ ];
116
+ return lines.join("\n");
117
+ }
118
+
119
+ function normalizeQuestions(raw: InterviewQuestionInput[], batchSize: number): InterviewQuestionInput[] {
120
+ const max = Math.max(1, Math.min(5, Math.floor(batchSize || 5)));
121
+ return raw
122
+ .filter((question) => question.question.trim().length > 0)
123
+ .slice(0, max)
124
+ .map((question, index) => ({
125
+ id: question.id?.trim() || `q${index + 1}`,
126
+ question: compact(question.question, 160),
127
+ allowCustom: question.allowCustom !== false,
128
+ choices: normalizeChoices(question.choices),
129
+ }));
130
+ }
131
+
132
+ function normalizeChoices(choices: InterviewChoiceInput[]): InterviewChoiceInput[] {
133
+ const normalized = choices
134
+ .filter((choice) => choice.label.trim().length > 0)
135
+ .slice(0, 5)
136
+ .map((choice) => ({ ...choice, label: compact(choice.label, 72), value: choice.value ? compact(choice.value, 180) : undefined }));
137
+ if (normalized.some((choice) => choice.recommended)) return normalized;
138
+ return normalized.map((choice, index) => ({ ...choice, recommended: index === 0 }));
139
+ }
140
+
141
+ function questionOptions(question: InterviewQuestionInput): string[] {
142
+ const recommended = recommendedChoice(question);
143
+ return [
144
+ ...question.choices.map((choice) => formatChoice(choice, recommended)),
145
+ ...(question.allowCustom === false ? [] : [CUSTOM_OPTION]),
146
+ SKIP_OPTION,
147
+ ];
148
+ }
149
+
150
+ function recommendedChoice(question: InterviewQuestionInput): InterviewChoiceInput | undefined {
151
+ return question.choices.find((choice) => choice.recommended) ?? question.choices[0];
152
+ }
153
+
154
+ function formatChoice(choice: InterviewChoiceInput, recommended: InterviewChoiceInput | undefined): string {
155
+ const isRecommended = choice === recommended || choice.recommended;
156
+ return `${choice.label}${isRecommended ? " (RECOMMENDED)" : ""}`;
157
+ }
158
+
159
+ function formatQuestionTitle(current: number, total: number, question: string): string {
160
+ return `pi-chalin interview ${current}/${total} · ${compact(question, 70)}`;
161
+ }
162
+
163
+ function formatInterviewRequest(task: string, reason: string, questions: InterviewQuestionInput[]): string {
164
+ return [
165
+ "pi-chalin interview required",
166
+ `task: ${compact(task, 140)}`,
167
+ `reason: ${compact(reason, 140)}`,
168
+ ...questions.map((question, index) => `${index + 1}. ${question.question}\n ${question.choices.map((choice) => `- ${choice.label}${choice.recommended ? " (recommended)" : ""}`).join("\n ")}`),
169
+ ].join("\n");
170
+ }
171
+
172
+ async function persistInterview(store: ArtifactStore, result: InterviewResult) {
173
+ const decision: InterviewDecisionInput = {
174
+ task: result.task,
175
+ reason: result.reason,
176
+ answers: result.answers.map((answer) => ({
177
+ questionId: answer.questionId,
178
+ question: answer.question,
179
+ answer: answer.answer,
180
+ custom: answer.custom,
181
+ recommended: answer.recommended,
182
+ })),
183
+ status: result.status,
184
+ };
185
+ await store.appendInterviewDecision(result.featureId, decision);
186
+ return store.appendCheckpoint(result.featureId, {
187
+ agent: "interview",
188
+ title: result.status === "answered" ? "Interview answers captured" : "Interview needs user input",
189
+ summary: result.answers.length ? result.answers.map((answer) => `${answer.question}: ${answer.answer}`).join("; ") : `No answers captured. ${result.reason}`,
190
+ status: result.status === "answered" ? "active" : "paused",
191
+ stage: "interview",
192
+ });
193
+ }
194
+
195
+ function safeFeatureId(value: string): string {
196
+ return compact(value, 80).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "interview";
197
+ }
198
+
199
+ function compact(text: string, max: number): string {
200
+ const normalized = text.replace(/\s+/g, " ").trim();
201
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
202
+ }
package/src/kernel.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { AgentCatalog } from "./agents.ts";
2
+ import { ArtifactStore } from "./artifacts.ts";
3
+ import { approvalDecision, type ChalinConfig } from "./config.ts";
4
+ import { MemoryStore } from "./memory.ts";
5
+ import { MockWorkerRunner, SdkWorkerRunner, type WorkerRunner, type WorkerRunnerContext } from "./runner.ts";
6
+ import type { AgentDefinition, AgentStage, AgentStep, AgentThinkingLevel, ApprovalDecision, MemoryRecord, RouteDecision, RoutePlan, RunState } from "./schemas.ts";
7
+
8
+ export interface ChalinKernelOptions {
9
+ cwd?: string;
10
+ config?: ChalinConfig;
11
+ catalog?: AgentCatalog;
12
+ memory?: MemoryStore;
13
+ artifacts?: ArtifactStore;
14
+ runner?: WorkerRunner;
15
+ sdkRunner?: WorkerRunner;
16
+ modelOverrides?: Record<string, string>;
17
+ thinkingOverrides?: Record<string, AgentThinkingLevel>;
18
+ }
19
+
20
+ export interface ChalinHandleResult {
21
+ route: RouteDecision;
22
+ approval: ApprovalDecision;
23
+ run?: RunState;
24
+ memories: MemoryRecord[];
25
+ diagnostics: string[];
26
+ }
27
+
28
+ export class ChalinKernel {
29
+ private readonly cwd: string;
30
+ private readonly config: ChalinConfig;
31
+ private readonly catalog: AgentCatalog;
32
+ private readonly memory: MemoryStore;
33
+ private readonly artifacts: ArtifactStore;
34
+ private readonly runner: WorkerRunner;
35
+ private readonly sdkRunner: WorkerRunner;
36
+ private readonly modelOverrides: Record<string, string>;
37
+ private readonly thinkingOverrides: Record<string, AgentThinkingLevel>;
38
+
39
+ constructor(options?: ChalinKernelOptions) {
40
+ this.cwd = options?.cwd ?? process.cwd();
41
+ this.config = options?.config ?? {
42
+ enabled: true,
43
+ autonomy: "balanced",
44
+ safety: { approvalRiskThreshold: "medium", recursionGuard: true, singleWriterGuard: true, mutationExpectationGuard: true, blockCritical: true },
45
+ agents: { modelOverrides: {}, thinkingOverrides: {}, modelPersistenceDefaults: { "built-in": "user", user: "user", project: "project" } },
46
+ };
47
+ this.catalog = options?.catalog ?? AgentCatalog.load({ cwd: this.cwd });
48
+ this.memory = options?.memory ?? new MemoryStore({ cwd: this.cwd });
49
+ this.artifacts = options?.artifacts ?? new ArtifactStore({ cwd: this.cwd });
50
+ this.runner = options?.runner ?? new MockWorkerRunner();
51
+ this.sdkRunner = options?.sdkRunner ?? new SdkWorkerRunner();
52
+ this.modelOverrides = options?.modelOverrides ?? this.config.agents.modelOverrides;
53
+ this.thinkingOverrides = options?.thinkingOverrides ?? this.config.agents.thinkingOverrides;
54
+ }
55
+
56
+ /**
57
+ * pi-chalin is LLM-routed: the primary Pi agent decides whether to call the
58
+ * chalin_route tool and provides the topology/steps. This method remains as a
59
+ * safe legacy preview path, but it intentionally does not infer workflows from
60
+ * hard-coded prompt keywords.
61
+ */
62
+ classify(prompt: string): RouteDecision {
63
+ const text = prompt.trim();
64
+ if (!text) return askUser("Prompt is empty.");
65
+ return {
66
+ kind: "bypass",
67
+ agents: [],
68
+ risk: "low",
69
+ ambiguity: "low",
70
+ needsMemory: false,
71
+ needsArtifacts: false,
72
+ reason: "pi-chalin uses LLM-first routing: the primary Pi agent decides when to call chalin_route and which agents/topology to use.",
73
+ };
74
+ }
75
+
76
+ classifyPlaceholder(prompt: string): RouteDecision {
77
+ return this.classify(prompt);
78
+ }
79
+
80
+ async handlePrompt(prompt: string, context: Omit<WorkerRunnerContext, "agents" | "modelOverrides"> = { cwd: this.cwd }): Promise<ChalinHandleResult> {
81
+ return this.handleRoute(this.classify(prompt), prompt, context);
82
+ }
83
+
84
+ async handleRoute(route: RouteDecision, prompt: string, context: Omit<WorkerRunnerContext, "agents" | "modelOverrides"> = { cwd: this.cwd }, approvalOverride?: ApprovalDecision): Promise<ChalinHandleResult> {
85
+ const approval = approvalOverride ?? approvalDecision(this.config, route);
86
+ const diagnostics = [...this.catalog.diagnostics.warnings, ...this.catalog.diagnostics.errors];
87
+ const memories = route.needsMemory ? (await this.memory.retrieve({ query: prompt, sourceAgent: "primary-pi", limit: 5, tokenBudget: 900 })).results.map((result) => result.record) : [];
88
+ if (approval.action !== "allow" || !route.plan) return { route, approval, memories, diagnostics };
89
+
90
+ const agents = this.resolvePlanAgents(route);
91
+ const missing = route.agents.filter((ref) => !agents.has(ref));
92
+ if (missing.length > 0) {
93
+ return {
94
+ route,
95
+ approval: { action: "block", reason: `Unknown pi-chalin agent(s): ${missing.join(", ")}.` },
96
+ memories,
97
+ diagnostics: [...diagnostics, `Unknown pi-chalin agent(s): ${missing.join(", ")}.`],
98
+ };
99
+ }
100
+
101
+ const runner = context.extensionContext ? this.sdkRunner : this.runner;
102
+ const run = await runner.run(route, { ...context, cwd: this.cwd, agents, modelOverrides: this.modelOverrides, thinkingOverrides: this.thinkingOverrides });
103
+ const candidates = run.steps.flatMap((step) => step.output?.memoryCandidates ?? []);
104
+ if (candidates.length > 0) {
105
+ if (context.extensionContext) {
106
+ this.persistMemoriesAfterToolResult(candidates, run.id, context.extensionContext.hasUI);
107
+ } else {
108
+ await this.memory.submitCandidates(candidates);
109
+ }
110
+ }
111
+ if (route.needsArtifacts || run.steps.length > 1) await this.artifacts.recordRun(run);
112
+ return { route, approval, run, memories, diagnostics };
113
+ }
114
+
115
+ async resumeRun(run: RunState, context: Omit<WorkerRunnerContext, "agents" | "modelOverrides" | "thinkingOverrides"> = { cwd: this.cwd }): Promise<ChalinHandleResult> {
116
+ const approval = approvalDecision(this.config, run.route);
117
+ const diagnostics = [...this.catalog.diagnostics.warnings, ...this.catalog.diagnostics.errors];
118
+ if (approval.action !== "allow" || !run.route.plan) return { route: run.route, approval, memories: [], diagnostics, run };
119
+ const agents = this.resolvePlanAgents(run.route);
120
+ const runner = context.extensionContext ? this.sdkRunner : this.runner;
121
+ const resumed = runner.resume
122
+ ? await runner.resume(run, { ...context, cwd: this.cwd, agents, modelOverrides: this.modelOverrides, thinkingOverrides: this.thinkingOverrides })
123
+ : await runner.run(run.route, { ...context, cwd: this.cwd, agents, modelOverrides: this.modelOverrides, thinkingOverrides: this.thinkingOverrides });
124
+ const candidates = resumed.steps.flatMap((step) => step.output?.memoryCandidates ?? []);
125
+ if (candidates.length > 0) {
126
+ if (context.extensionContext) this.persistMemoriesAfterToolResult(candidates, resumed.id, context.extensionContext.hasUI);
127
+ else await this.memory.submitCandidates(candidates);
128
+ }
129
+ await this.artifacts.recordRun(resumed);
130
+ return { route: resumed.route, approval, run: resumed, memories: [], diagnostics };
131
+ }
132
+
133
+ async approvalFor(route: RouteDecision): Promise<ApprovalDecision> {
134
+ return approvalDecision(this.config, route);
135
+ }
136
+
137
+ resolvePlanAgents(route: RouteDecision): Map<string, AgentDefinition> {
138
+ const result = new Map<string, AgentDefinition>();
139
+ for (const ref of route.agents) {
140
+ const resolved = this.catalog.resolve(ref);
141
+ if (resolved.agent) result.set(resolved.agent.name, resolved.agent);
142
+ }
143
+ const conflictResolver = this.catalog.resolve("conflict-resolver").agent;
144
+ if (conflictResolver) result.set(conflictResolver.name, conflictResolver);
145
+ return result;
146
+ }
147
+
148
+ private persistMemoriesAfterToolResult(candidates: NonNullable<RunState["steps"][number]["output"]>["memoryCandidates"], runId: string, hasUI: boolean): void {
149
+ const configuredDelay = Number(process.env.PI_CHALIN_MEMORY_PERSIST_DELAY_MS);
150
+ const delayMs = Number.isFinite(configuredDelay) && configuredDelay >= 0 ? configuredDelay : hasUI ? 0 : 30_000;
151
+ const timer = setTimeout(() => {
152
+ void this.memory.submitCandidates(candidates).catch((error) => {
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ console.warn(`pi-chalin memory persistence failed after run ${runId}: ${message}`);
155
+ });
156
+ }, delayMs);
157
+ timer.unref?.();
158
+ }
159
+ }
160
+
161
+ function askUser(reason: string): RouteDecision {
162
+ return { kind: "ask-user", agents: [], risk: "low", ambiguity: "high", needsMemory: false, needsArtifacts: false, reason };
163
+ }
164
+
165
+ export function routeFromPlan(input: {
166
+ topology: "single" | "chain" | "parallel" | "dag" | "memory-only";
167
+ steps?: AgentStep[];
168
+ stages?: Array<{ id?: string; name?: string; tasks: AgentStep[] }>;
169
+ risk?: RouteDecision["risk"];
170
+ needsMemory?: boolean;
171
+ needsArtifacts?: boolean;
172
+ reason?: string;
173
+ }): RouteDecision {
174
+ const steps = sanitizeSteps(input.steps ?? []);
175
+ if (input.topology === "memory-only") {
176
+ return {
177
+ kind: "memory-only",
178
+ agents: [],
179
+ risk: input.risk ?? "low",
180
+ ambiguity: "low",
181
+ needsMemory: true,
182
+ needsArtifacts: false,
183
+ reason: input.reason?.trim() || "Primary Pi agent requested pi-chalin memory lookup.",
184
+ };
185
+ }
186
+ if (input.topology === "dag") {
187
+ const stages = sanitizeStages(input.stages ?? []);
188
+ if (stages.length === 0) return askUser("chalin_route dag topology requires at least one stage with agent tasks.");
189
+ const agents = stages.flatMap((stage) => stage.tasks.map((step) => step.agent));
190
+ const allSteps = stages.flatMap((stage) => stage.tasks);
191
+ return {
192
+ kind: "multi-agent-dag",
193
+ agents,
194
+ risk: input.risk ?? riskFromPlan(allSteps),
195
+ ambiguity: "low",
196
+ needsMemory: Boolean(input.needsMemory),
197
+ needsArtifacts: input.needsArtifacts ?? allSteps.some((step) => ["scout", "planner", "worker", "reviewer", "context-builder"].includes(step.agent)),
198
+ reason: input.reason?.trim() || "Primary Pi agent selected a staged DAG workflow dynamically.",
199
+ plan: { kind: "dag", stages },
200
+ };
201
+ }
202
+ if (steps.length === 0) return askUser("chalin_route requires at least one agent step unless topology is memory-only.");
203
+
204
+ const agents = steps.map((step) => step.agent);
205
+ const plan = planFromTopology(input.topology, steps);
206
+ return {
207
+ kind: kindFromTopology(input.topology, steps.length),
208
+ agents,
209
+ risk: input.risk ?? riskFromPlan(steps),
210
+ ambiguity: "low",
211
+ needsMemory: Boolean(input.needsMemory),
212
+ needsArtifacts: input.needsArtifacts ?? steps.some((step) => ["scout", "planner", "worker", "reviewer", "context-builder"].includes(step.agent)),
213
+ reason: input.reason?.trim() || "Primary Pi agent selected this chalin workflow dynamically.",
214
+ plan,
215
+ };
216
+ }
217
+
218
+ function sanitizeSteps(steps: AgentStep[]): AgentStep[] {
219
+ return steps
220
+ .map((step) => ({ id: step.id?.trim(), agent: step.agent.trim(), task: step.task.trim(), budget: sanitizeBudget(step.budget) }))
221
+ .filter((step) => step.agent.length > 0 && step.task.length > 0)
222
+ .slice(0, 6);
223
+ }
224
+
225
+ function sanitizeStages(stages: Array<{ id?: string; name?: string; tasks: AgentStep[] }>): AgentStage[] {
226
+ return stages
227
+ .map((stage, index) => ({
228
+ id: (stage.id ?? stage.name ?? `stage-${index + 1}`).trim() || `stage-${index + 1}`,
229
+ tasks: sanitizeSteps(stage.tasks).slice(0, 10),
230
+ }))
231
+ .filter((stage) => stage.tasks.length > 0)
232
+ .slice(0, 8);
233
+ }
234
+
235
+ function kindFromTopology(topology: "single" | "chain" | "parallel", stepCount: number): RouteDecision["kind"] {
236
+ if (topology === "parallel") return "multi-agent-parallel";
237
+ if (topology === "chain" || stepCount > 1) return "multi-agent-chain";
238
+ return "single-agent";
239
+ }
240
+
241
+ function planFromTopology(topology: "single" | "chain" | "parallel", steps: AgentStep[]): RoutePlan {
242
+ if (topology === "parallel") return { kind: "parallel", tasks: steps };
243
+ if (topology === "chain" || steps.length > 1) return { kind: "chain", steps };
244
+ return { kind: "single", agent: steps[0]!.agent, task: steps[0]!.task, budget: steps[0]!.budget };
245
+ }
246
+
247
+ function sanitizeBudget(value: AgentStep["budget"]): AgentStep["budget"] | undefined {
248
+ return value === "tight" || value === "normal" || value === "deep" || value === "extended" ? value : undefined;
249
+ }
250
+
251
+ function riskFromPlan(steps: Array<{ agent: string }>): RouteDecision["risk"] {
252
+ if (steps.some((step) => step.agent === "worker")) return "medium";
253
+ return "low";
254
+ }