@oyasmi/pipiclaw 0.2.0 → 0.2.1

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/dist/agent.js CHANGED
@@ -1,17 +1,16 @@
1
1
  import { Agent } from "@mariozechner/pi-agent-core";
2
- import { getModel } from "@mariozechner/pi-ai";
3
- import { AgentSession, AuthStorage, convertToLlm, DefaultResourceLoader, formatSkillsForPrompt, loadSkillsFromDir, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
4
- import { existsSync, readFileSync } from "fs";
2
+ import { AgentSession, AuthStorage, convertToLlm, DefaultResourceLoader, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
5
3
  import { mkdir, writeFile } from "fs/promises";
6
4
  import { basename, join } from "path";
7
5
  import { renderBuiltInHelp } from "./commands.js";
6
+ import { getAgentConfig, getApiKeyForModel, getMemory, getSoul, loadPipiclawSkills } from "./config-loader.js";
8
7
  import { PipiclawSettingsManager, syncLogToSessionManager } from "./context.js";
9
8
  import * as log from "./log.js";
9
+ import { findExactModelReferenceMatch, formatModelList, formatModelReference, resolveInitialModel, } from "./model-utils.js";
10
10
  import { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from "./paths.js";
11
+ import { buildSystemPrompt } from "./prompt-builder.js";
11
12
  import { createExecutor } from "./sandbox.js";
12
13
  import { createPipiclawTools } from "./tools/index.js";
13
- // Default model - will be overridden by ModelRegistry if custom models are configured
14
- const defaultModel = getModel("anthropic", "claude-sonnet-4-5");
15
14
  function isSilentOutcome(outcome) {
16
15
  return outcome.kind === "silent";
17
16
  }
@@ -21,372 +20,8 @@ function isFinalOutcome(outcome) {
21
20
  function getFinalOutcomeText(outcome) {
22
21
  return isFinalOutcome(outcome) ? outcome.text : null;
23
22
  }
24
- function formatModelReference(model) {
25
- return `${model.provider}/${model.id}`;
26
- }
27
- function findExactModelReferenceMatch(modelReference, availableModels) {
28
- const trimmedReference = modelReference.trim();
29
- if (!trimmedReference) {
30
- return { ambiguous: false };
31
- }
32
- const normalizedReference = trimmedReference.toLowerCase();
33
- const canonicalMatches = availableModels.filter((model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference);
34
- if (canonicalMatches.length === 1) {
35
- return { match: canonicalMatches[0], ambiguous: false };
36
- }
37
- if (canonicalMatches.length > 1) {
38
- return { ambiguous: true };
39
- }
40
- const slashIndex = trimmedReference.indexOf("/");
41
- if (slashIndex !== -1) {
42
- const provider = trimmedReference.substring(0, slashIndex).trim();
43
- const modelId = trimmedReference.substring(slashIndex + 1).trim();
44
- if (provider && modelId) {
45
- const providerMatches = availableModels.filter((model) => model.provider.toLowerCase() === provider.toLowerCase() &&
46
- model.id.toLowerCase() === modelId.toLowerCase());
47
- if (providerMatches.length === 1) {
48
- return { match: providerMatches[0], ambiguous: false };
49
- }
50
- if (providerMatches.length > 1) {
51
- return { ambiguous: true };
52
- }
53
- }
54
- }
55
- const idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);
56
- if (idMatches.length === 1) {
57
- return { match: idMatches[0], ambiguous: false };
58
- }
59
- return { ambiguous: idMatches.length > 1 };
60
- }
61
- function formatModelList(models, currentModel, limit = 20) {
62
- const refs = models
63
- .slice()
64
- .sort((a, b) => formatModelReference(a).localeCompare(formatModelReference(b)))
65
- .map((model) => {
66
- const ref = formatModelReference(model);
67
- const marker = currentModel && currentModel.provider === model.provider && currentModel.id === model.id
68
- ? " (current)"
69
- : "";
70
- return `- \`${ref}\`${marker}`;
71
- });
72
- if (refs.length <= limit) {
73
- return refs.join("\n");
74
- }
75
- return `${refs.slice(0, limit).join("\n")}\n- ... and ${refs.length - limit} more`;
76
- }
77
- function resolveInitialModel(modelRegistry, settingsManager) {
78
- const savedProvider = settingsManager.getDefaultProvider();
79
- const savedModelId = settingsManager.getDefaultModel();
80
- const availableModels = modelRegistry.getAvailable();
81
- if (savedProvider && savedModelId) {
82
- const savedModel = modelRegistry.find(savedProvider, savedModelId);
83
- if (savedModel &&
84
- availableModels.some((model) => model.provider === savedModel.provider && model.id === savedModel.id)) {
85
- return savedModel;
86
- }
87
- }
88
- if (availableModels.length > 0) {
89
- return availableModels[0];
90
- }
91
- return defaultModel;
92
- }
93
- async function getApiKeyForModel(modelRegistry, model) {
94
- const key = await modelRegistry.getApiKeyForProvider(model.provider);
95
- if (key)
96
- return key;
97
- // Fallback: try anthropic env var
98
- const envKey = process.env.ANTHROPIC_API_KEY;
99
- if (envKey)
100
- return envKey;
101
- throw new Error(`No API key found for provider: ${model.provider}.\n\n` +
102
- "Configure API key in ~/.pi/agent/models.json or set ANTHROPIC_API_KEY environment variable.");
103
- }
104
- // ============================================================================
105
- // Configuration file loaders: SOUL.md, AGENTS.md, MEMORY.md
106
- // ============================================================================
107
- /**
108
- * Load SOUL.md — defines the agent's identity, personality, and communication style.
109
- * Only loaded from workspace root (global).
110
- */
111
- function getSoul(workspaceDir) {
112
- const soulPath = join(workspaceDir, "SOUL.md");
113
- if (existsSync(soulPath)) {
114
- try {
115
- const content = readFileSync(soulPath, "utf-8").trim();
116
- if (content)
117
- return content;
118
- }
119
- catch (error) {
120
- log.logWarning("Failed to read SOUL.md", `${soulPath}: ${error}`);
121
- }
122
- }
123
- return "";
124
- }
125
- /**
126
- * Load AGENTS.md — defines the agent's behavior instructions, capabilities, and constraints.
127
- * Supports both global (workspace root) and channel-level override.
128
- */
129
- function getAgentConfig(channelDir) {
130
- const parts = [];
131
- // Read workspace-level AGENTS.md (global)
132
- const workspaceAgentPath = join(channelDir, "..", "AGENTS.md");
133
- if (existsSync(workspaceAgentPath)) {
134
- try {
135
- const content = readFileSync(workspaceAgentPath, "utf-8").trim();
136
- if (content) {
137
- parts.push(content);
138
- }
139
- }
140
- catch (error) {
141
- log.logWarning("Failed to read workspace AGENTS.md", `${workspaceAgentPath}: ${error}`);
142
- }
143
- }
144
- // Read channel-specific AGENTS.md (overrides/extends global)
145
- const channelAgentPath = join(channelDir, "AGENTS.md");
146
- if (existsSync(channelAgentPath)) {
147
- try {
148
- const content = readFileSync(channelAgentPath, "utf-8").trim();
149
- if (content) {
150
- parts.push(content);
151
- }
152
- }
153
- catch (error) {
154
- log.logWarning("Failed to read channel AGENTS.md", `${channelAgentPath}: ${error}`);
155
- }
156
- }
157
- return parts.join("\n\n");
158
- }
159
- function getMemory(channelDir) {
160
- const parts = [];
161
- // Read workspace-level memory (shared across all channels)
162
- const workspaceMemoryPath = join(channelDir, "..", "MEMORY.md");
163
- if (existsSync(workspaceMemoryPath)) {
164
- try {
165
- const content = readFileSync(workspaceMemoryPath, "utf-8").trim();
166
- if (content) {
167
- parts.push(`### Global Workspace Memory\n${content}`);
168
- }
169
- }
170
- catch (error) {
171
- log.logWarning("Failed to read workspace memory", `${workspaceMemoryPath}: ${error}`);
172
- }
173
- }
174
- // Read channel-specific memory
175
- const channelMemoryPath = join(channelDir, "MEMORY.md");
176
- if (existsSync(channelMemoryPath)) {
177
- try {
178
- const content = readFileSync(channelMemoryPath, "utf-8").trim();
179
- if (content) {
180
- parts.push(`### Channel-Specific Memory\n${content}`);
181
- }
182
- }
183
- catch (error) {
184
- log.logWarning("Failed to read channel memory", `${channelMemoryPath}: ${error}`);
185
- }
186
- }
187
- if (parts.length === 0) {
188
- return "(no working memory yet)";
189
- }
190
- const combined = parts.join("\n\n");
191
- // Warn if memory is getting too large (consumes system prompt token budget)
192
- if (combined.length > 5000) {
193
- return `\u26a0\ufe0f Memory is large (${combined.length} chars). Consolidate: remove outdated entries, merge duplicates, tighten descriptions.\n\n${combined}`;
194
- }
195
- return combined;
196
- }
197
- function loadPipiclawSkills(channelDir, workspacePath) {
198
- const skillMap = new Map();
199
- const hostWorkspacePath = join(channelDir, "..");
200
- const translatePath = (hostPath) => {
201
- if (hostPath.startsWith(hostWorkspacePath)) {
202
- return workspacePath + hostPath.slice(hostWorkspacePath.length);
203
- }
204
- return hostPath;
205
- };
206
- // Load workspace-level skills (global)
207
- const workspaceSkillsDir = join(hostWorkspacePath, "skills");
208
- for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: "workspace" }).skills) {
209
- skill.filePath = translatePath(skill.filePath);
210
- skill.baseDir = translatePath(skill.baseDir);
211
- skillMap.set(skill.name, skill);
212
- }
213
- // Load channel-specific skills
214
- const channelSkillsDir = join(channelDir, "skills");
215
- for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" }).skills) {
216
- skill.filePath = translatePath(skill.filePath);
217
- skill.baseDir = translatePath(skill.baseDir);
218
- skillMap.set(skill.name, skill);
219
- }
220
- return Array.from(skillMap.values());
221
- }
222
23
  // ============================================================================
223
- // System Prompt Builder
224
- // ============================================================================
225
- function buildSystemPrompt(workspacePath, channelId, soul, agentConfig, memory, sandboxConfig, skills) {
226
- const channelPath = `${workspacePath}/${channelId}`;
227
- const isDocker = sandboxConfig.type === "docker";
228
- const envDescription = isDocker
229
- ? `You are running inside a Docker container (Alpine Linux).
230
- - Bash working directory: / (use cd or absolute paths)
231
- - Install tools with: apk add <package>
232
- - Your changes persist across sessions`
233
- : `You are running directly on the host machine.
234
- - Bash working directory: ${process.cwd()}
235
- - Be careful with system modifications`;
236
- // Build system prompt with configuration file layering:
237
- // 1. SOUL.md (identity/personality)
238
- // 2. Core instructions
239
- // 3. AGENTS.md (behavior instructions)
240
- // 4. Skills, Events, Memory
241
- const sections = [];
242
- // 1. SOUL.md — Agent identity
243
- if (soul) {
244
- sections.push(soul);
245
- }
246
- else {
247
- sections.push("You are a DingTalk bot assistant. Be concise and helpful.");
248
- }
249
- // 2. Core instructions
250
- sections.push(`## Context
251
- - For current date/time, use: date
252
- - You have access to previous conversation context including tool results from prior turns.
253
- - For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).
254
-
255
- ## Formatting
256
- Use Markdown for formatting. DingTalk AI Card supports basic Markdown:
257
- Bold: **text**, Italic: *text*, Code: \`code\`, Block: \`\`\`code\`\`\`, Links: [text](url)
258
-
259
- ## Environment
260
- ${envDescription}
261
-
262
- ## Workspace Layout
263
- ${workspacePath}/
264
- ├── SOUL.md # Your identity/personality (read-only)
265
- ├── AGENTS.md # Custom behavior instructions (read-only)
266
- ├── MEMORY.md # Global memory (all channels, you can read/write)
267
- ├── skills/ # Global CLI tools you create
268
- ├── events/ # Scheduled events
269
- └── ${channelId}/ # This channel
270
- ├── AGENTS.md # Channel-specific instructions (read-only)
271
- ├── MEMORY.md # Channel-specific memory (you can read/write)
272
- ├── log.jsonl # Message history (no tool results)
273
- ├── scratch/ # Your working directory
274
- └── skills/ # Channel-specific tools`);
275
- // 3. AGENTS.md — User-defined instructions
276
- if (agentConfig) {
277
- sections.push(`## Agent Instructions\n${agentConfig}`);
278
- }
279
- // 4. Skills
280
- sections.push(`## Skills (Custom CLI Tools)
281
- You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).
282
-
283
- ### Creating Skills
284
- Store in \`${workspacePath}/skills/<name>/\` (global) or \`${channelPath}/skills/<name>/\` (channel-specific).
285
- Each skill directory needs a \`SKILL.md\` with YAML frontmatter:
286
-
287
- \`\`\`markdown
288
- ---
289
- name: skill-name
290
- description: Short description of what this skill does
291
- ---
292
-
293
- # Skill Name
294
-
295
- Usage instructions, examples, etc.
296
- Scripts are in: {baseDir}/
297
- \`\`\`
298
-
299
- \`name\` and \`description\` are required. Use \`{baseDir}\` as placeholder for the skill's directory path.
300
-
301
- ### Available Skills
302
- ${skills.length > 0 ? formatSkillsForPrompt(skills) : "(no skills installed yet)"}`);
303
- // 5. Events
304
- sections.push(`## Events
305
- You can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \`${workspacePath}/events/\`.
306
-
307
- ### Event Types
308
-
309
- **Immediate** - Triggers as soon as harness sees the file.
310
- \`\`\`json
311
- {"type": "immediate", "channelId": "${channelId}", "text": "New event occurred"}
312
- \`\`\`
313
-
314
- **One-shot** - Triggers once at a specific time.
315
- \`\`\`json
316
- {"type": "one-shot", "channelId": "${channelId}", "text": "Reminder", "at": "2025-12-15T09:00:00+08:00"}
317
- \`\`\`
318
-
319
- **Periodic** - Triggers on a cron schedule.
320
- \`\`\`json
321
- {"type": "periodic", "channelId": "${channelId}", "text": "Check inbox", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
322
- \`\`\`
323
-
324
- ### Cron Format
325
- \`minute hour day-of-month month day-of-week\`
326
-
327
- ### Creating Events
328
- \`\`\`bash
329
- cat > ${workspacePath}/events/reminder-$(date +%s).json << 'EOF'
330
- {"type": "one-shot", "channelId": "${channelId}", "text": "Reminder text", "at": "2025-12-14T09:00:00+08:00"}
331
- EOF
332
- \`\`\`
333
-
334
- ### Silent Completion
335
- For periodic events where there's nothing to report, respond with just \`[SILENT]\`. This deletes the status message. Use this to avoid spam when periodic checks find nothing.
336
-
337
- ### Limits
338
- Maximum 5 events can be queued.`);
339
- // 6. Memory
340
- sections.push(`## Memory
341
- Write to MEMORY.md files to persist context across conversations.
342
- - Global (${workspacePath}/MEMORY.md): skills, preferences, project info
343
- - Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work
344
-
345
- ### Guidelines
346
- - Keep each MEMORY.md concise (target: under 50 lines)
347
- - Use clear headers to organize entries (## Preferences, ## Projects, etc.)
348
- - Remove outdated entries when they are no longer relevant
349
- - Merge duplicate or redundant items
350
- - Prefer structured formats (lists, key-value pairs) over prose
351
- - Update when you learn something important or when asked to remember something
352
-
353
- ### Current Memory
354
- ${memory}`);
355
- // 7. System Configuration Log
356
- sections.push(`## System Configuration Log
357
- Maintain ${workspacePath}/SYSTEM.md to log all environment modifications:
358
- - Installed packages (apk add, npm install, pip install)
359
- - Environment variables set
360
- - Config files modified
361
- - Skill dependencies installed
362
-
363
- Update this file whenever you modify the environment.`);
364
- // 8. Tools
365
- sections.push(`## Tools
366
- - bash: Run shell commands (primary tool). Install packages as needed.
367
- - read: Read files
368
- - write: Create/overwrite files
369
- - edit: Surgical file edits
370
- - attach: Share files (note: DingTalk file sharing is limited, output as text when possible)
371
-
372
- Each tool requires a "label" parameter (shown to user).`);
373
- // 9. Log Queries
374
- sections.push(`## Log Queries (for older history)
375
- Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
376
- The log contains user messages and your final responses (not tool calls/results).
377
- ${isDocker ? "Install jq: apk add jq" : ""}
378
-
379
- \`\`\`bash
380
- # Recent messages
381
- tail -30 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
382
-
383
- # Search for specific topic
384
- grep -i "topic" log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
385
- \`\`\``);
386
- return sections.join("\n\n");
387
- }
388
- // ============================================================================
389
- // Agent Runner
24
+ // Text helpers
390
25
  // ============================================================================
391
26
  function truncate(text, maxLen) {
392
27
  if (text.length <= maxLen)
@@ -436,78 +71,8 @@ function extractToolResultText(result) {
436
71
  }
437
72
  return JSON.stringify(result);
438
73
  }
439
- // Cache runners per channel
440
- const channelRunners = new Map();
441
- export function getOrCreateRunner(sandboxConfig, channelId, channelDir) {
442
- const existing = channelRunners.get(channelId);
443
- if (existing)
444
- return existing;
445
- const runner = createRunner(sandboxConfig, channelId, channelDir);
446
- channelRunners.set(channelId, runner);
447
- return runner;
448
- }
449
- function createRunner(sandboxConfig, channelId, channelDir) {
450
- const executor = createExecutor(sandboxConfig);
451
- const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
452
- const workspaceDir = join(channelDir, "..");
453
- // Create tools
454
- const tools = createPipiclawTools(executor);
455
- // Initial system prompt
456
- const soul = getSoul(workspaceDir);
457
- const agentConfig = getAgentConfig(channelDir);
458
- const memory = getMemory(channelDir);
459
- const initialSkills = loadPipiclawSkills(channelDir, workspacePath);
460
- let currentSkills = initialSkills;
461
- const systemPrompt = buildSystemPrompt(workspacePath, channelId, soul, agentConfig, memory, sandboxConfig, initialSkills);
462
- // Create session manager
463
- const contextFile = join(channelDir, "context.jsonl");
464
- const sessionManager = SessionManager.open(contextFile, channelDir);
465
- const settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);
466
- // Create AuthStorage and ModelRegistry
467
- const authStorage = AuthStorage.create(AUTH_CONFIG_PATH);
468
- const modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);
469
- // Resolve model: prefer saved global default, fall back to first available model
470
- let activeModel = resolveInitialModel(modelRegistry, settingsManager);
471
- log.logInfo(`Using model: ${activeModel.provider}/${activeModel.id} (${activeModel.name})`);
472
- // Create agent
473
- const agent = new Agent({
474
- initialState: {
475
- systemPrompt,
476
- model: activeModel,
477
- thinkingLevel: "off",
478
- tools,
479
- },
480
- convertToLlm,
481
- getApiKey: async () => getApiKeyForModel(modelRegistry, activeModel),
482
- });
483
- // Load existing messages
484
- const loadedSession = sessionManager.buildSessionContext();
485
- if (loadedSession.messages.length > 0) {
486
- agent.replaceMessages(loadedSession.messages);
487
- log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
488
- }
489
- const resourceLoader = new DefaultResourceLoader({
490
- cwd: process.cwd(),
491
- agentDir: APP_HOME_DIR,
492
- settingsManager: settingsManager,
493
- skillsOverride: (base) => ({
494
- skills: [...base.skills, ...currentSkills],
495
- diagnostics: base.diagnostics,
496
- }),
497
- });
498
- const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
499
- // Create AgentSession
500
- const session = new AgentSession({
501
- agent,
502
- sessionManager,
503
- settingsManager: settingsManager,
504
- cwd: process.cwd(),
505
- modelRegistry,
506
- resourceLoader,
507
- baseToolsOverride,
508
- });
509
- // Mutable per-run state
510
- const runState = {
74
+ function createEmptyRunState() {
75
+ return {
511
76
  ctx: null,
512
77
  logCtx: null,
513
78
  queue: null,
@@ -524,450 +89,491 @@ function createRunner(sandboxConfig, channelId, channelDir) {
524
89
  finalOutcome: { kind: "none" },
525
90
  finalResponseDelivered: false,
526
91
  };
527
- const sendCommandReply = async (ctx, text) => {
528
- const delivered = await ctx.respondPlain(text);
529
- if (!delivered) {
530
- await ctx.replaceMessage(text);
531
- await ctx.flush();
532
- }
533
- };
534
- const requireQueuedMessage = (text, commandName) => {
535
- const trimmedText = text.trim();
536
- if (!trimmedText) {
537
- throw new Error(`/${commandName} requires a message.`);
538
- }
539
- return trimmedText;
540
- };
541
- const queueBusyMessage = async (delivery, text) => {
542
- if (!session.isStreaming) {
543
- throw new Error("No task is currently running.");
544
- }
545
- if (delivery === "followUp") {
546
- await session.followUp(text);
547
- }
548
- else {
549
- await session.steer(text);
550
- }
551
- };
552
- const handleModelBuiltinCommand = async (ctx, args) => {
553
- modelRegistry.refresh();
554
- const availableModels = await modelRegistry.getAvailable();
555
- const currentModel = session.model;
556
- if (!args.trim()) {
557
- const current = currentModel ? `\`${formatModelReference(currentModel)}\`` : "(none)";
558
- const available = availableModels.length > 0 ? formatModelList(availableModels, currentModel) : "- (none)";
559
- await sendCommandReply(ctx, `# Model
560
-
561
- Current model: ${current}
562
-
563
- Use \`/model <provider/modelId>\` or \`/model <modelId>\` to switch. Bare model IDs must resolve uniquely.
564
-
565
- Available models:
566
- ${available}`);
567
- return;
568
- }
569
- const match = findExactModelReferenceMatch(args, availableModels);
570
- if (match.match) {
571
- await session.setModel(match.match);
572
- activeModel = match.match;
573
- await sendCommandReply(ctx, `已切换模型到 \`${formatModelReference(match.match)}\`。`);
574
- return;
92
+ }
93
+ // ============================================================================
94
+ // ChannelRunner
95
+ // ============================================================================
96
+ class ChannelRunner {
97
+ // --- Constructed once ---
98
+ sandboxConfig;
99
+ channelId;
100
+ channelDir;
101
+ workspacePath;
102
+ workspaceDir;
103
+ session;
104
+ agent;
105
+ sessionManager;
106
+ settingsManager;
107
+ modelRegistry;
108
+ // --- Mutable across runs ---
109
+ activeModel;
110
+ currentSkills;
111
+ // --- Per run ---
112
+ runState = createEmptyRunState();
113
+ constructor(sandboxConfig, channelId, channelDir) {
114
+ this.sandboxConfig = sandboxConfig;
115
+ this.channelId = channelId;
116
+ this.channelDir = channelDir;
117
+ const executor = createExecutor(sandboxConfig);
118
+ this.workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
119
+ this.workspaceDir = join(channelDir, "..");
120
+ // Create tools
121
+ const tools = createPipiclawTools(executor);
122
+ // Initial system prompt
123
+ const soul = getSoul(this.workspaceDir);
124
+ const agentConfig = getAgentConfig(channelDir);
125
+ const memory = getMemory(channelDir);
126
+ const initialSkills = loadPipiclawSkills(channelDir, this.workspacePath);
127
+ this.currentSkills = initialSkills;
128
+ const systemPrompt = buildSystemPrompt(this.workspacePath, channelId, soul, agentConfig, memory, sandboxConfig, initialSkills);
129
+ // Create session manager
130
+ const contextFile = join(channelDir, "context.jsonl");
131
+ this.sessionManager = SessionManager.open(contextFile, channelDir);
132
+ this.settingsManager = new PipiclawSettingsManager(APP_HOME_DIR);
133
+ // Create AuthStorage and ModelRegistry
134
+ const authStorage = AuthStorage.create(AUTH_CONFIG_PATH);
135
+ this.modelRegistry = new ModelRegistry(authStorage, MODELS_CONFIG_PATH);
136
+ // Resolve model: prefer saved global default, fall back to first available model
137
+ this.activeModel = resolveInitialModel(this.modelRegistry, this.settingsManager);
138
+ log.logInfo(`Using model: ${this.activeModel.provider}/${this.activeModel.id} (${this.activeModel.name})`);
139
+ // Create agent
140
+ this.agent = new Agent({
141
+ initialState: {
142
+ systemPrompt,
143
+ model: this.activeModel,
144
+ thinkingLevel: "off",
145
+ tools,
146
+ },
147
+ convertToLlm,
148
+ getApiKey: async () => getApiKeyForModel(this.modelRegistry, this.activeModel),
149
+ });
150
+ // Load existing messages
151
+ const loadedSession = this.sessionManager.buildSessionContext();
152
+ if (loadedSession.messages.length > 0) {
153
+ this.agent.replaceMessages(loadedSession.messages);
154
+ log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
155
+ }
156
+ const resourceLoader = new DefaultResourceLoader({
157
+ cwd: process.cwd(),
158
+ agentDir: APP_HOME_DIR,
159
+ settingsManager: this.settingsManager,
160
+ skillsOverride: (base) => ({
161
+ skills: [...base.skills, ...this.currentSkills],
162
+ diagnostics: base.diagnostics,
163
+ }),
164
+ });
165
+ const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
166
+ // Create AgentSession
167
+ this.session = new AgentSession({
168
+ agent: this.agent,
169
+ sessionManager: this.sessionManager,
170
+ settingsManager: this.settingsManager,
171
+ cwd: process.cwd(),
172
+ modelRegistry: this.modelRegistry,
173
+ resourceLoader,
174
+ baseToolsOverride,
175
+ });
176
+ // Subscribe to session events
177
+ this.subscribeToSessionEvents();
178
+ }
179
+ // === Public API ===
180
+ async run(ctx, _store) {
181
+ this.resetRunState(ctx);
182
+ // Create queue for this run
183
+ let queueChain = Promise.resolve();
184
+ this.runState.queue = {
185
+ enqueue: (fn, errorContext) => {
186
+ queueChain = queueChain.then(async () => {
187
+ try {
188
+ await fn();
189
+ }
190
+ catch (err) {
191
+ const errMsg = err instanceof Error ? err.message : String(err);
192
+ log.logWarning(`DingTalk API error (${errorContext})`, errMsg);
193
+ }
194
+ });
195
+ },
196
+ enqueueMessage: function (text, target, errorContext, doLog = true) {
197
+ this.enqueue(() => (target === "main" ? ctx.respond(text, doLog) : ctx.respondInThread(text)), errorContext);
198
+ },
199
+ };
200
+ try {
201
+ // Ensure channel directory exists
202
+ await mkdir(this.channelDir, { recursive: true });
203
+ // Update system prompt and runtime resources with fresh config
204
+ const soul = getSoul(this.workspaceDir);
205
+ const agentConfig = getAgentConfig(this.channelDir);
206
+ const memory = getMemory(this.channelDir);
207
+ const skills = loadPipiclawSkills(this.channelDir, this.workspacePath);
208
+ this.currentSkills = skills;
209
+ const systemPrompt = buildSystemPrompt(this.workspacePath, this.channelId, soul, agentConfig, memory, this.sandboxConfig, skills);
210
+ this.session.agent.setSystemPrompt(systemPrompt);
211
+ await this.session.reload();
212
+ // Sync messages from log.jsonl
213
+ const syncedCount = syncLogToSessionManager(this.sessionManager, this.channelDir, ctx.message.ts);
214
+ if (syncedCount > 0) {
215
+ log.logInfo(`[${this.channelId}] Synced ${syncedCount} messages from log.jsonl`);
216
+ }
217
+ // Reload messages from context.jsonl
218
+ const reloadedSession = this.sessionManager.buildSessionContext();
219
+ if (reloadedSession.messages.length > 0) {
220
+ this.agent.replaceMessages(reloadedSession.messages);
221
+ log.logInfo(`[${this.channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
222
+ }
223
+ // Log context info
224
+ log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
225
+ // Build user message with timestamp and username prefix
226
+ const now = new Date();
227
+ const pad = (n) => n.toString().padStart(2, "0");
228
+ const offset = -now.getTimezoneOffset();
229
+ const offsetSign = offset >= 0 ? "+" : "-";
230
+ const offsetHours = pad(Math.floor(Math.abs(offset) / 60));
231
+ const offsetMins = pad(Math.abs(offset) % 60);
232
+ const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;
233
+ const userMessage = `[${timestamp}] [${ctx.message.userName || "unknown"}]: ${ctx.message.text}`;
234
+ // Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)
235
+ if (process.env.PIPICLAW_DEBUG) {
236
+ const debugContext = {
237
+ systemPrompt,
238
+ messages: this.session.messages,
239
+ newUserMessage: userMessage,
240
+ };
241
+ await writeFile(join(this.channelDir, "last_prompt.json"), JSON.stringify(debugContext, null, 2));
242
+ }
243
+ await this.session.prompt(userMessage);
575
244
  }
576
- const available = availableModels.length > 0 ? formatModelList(availableModels, currentModel, 10) : "- (none)";
577
- if (match.ambiguous) {
578
- await sendCommandReply(ctx, `未切换模型:\`${args.trim()}\` 匹配到多个模型。请改用精确的 \`provider/modelId\` 形式。
579
-
580
- Available models:
581
- ${available}`);
582
- return;
245
+ catch (err) {
246
+ this.runState.stopReason = "error";
247
+ this.runState.errorMessage = err instanceof Error ? err.message : String(err);
248
+ log.logWarning(`[${this.channelId}] Runner failed`, this.runState.errorMessage);
249
+ }
250
+ finally {
251
+ await queueChain;
252
+ const finalOutcome = this.runState.finalOutcome;
253
+ const finalOutcomeText = getFinalOutcomeText(finalOutcome);
254
+ try {
255
+ if (this.runState.stopReason === "error" &&
256
+ this.runState.errorMessage &&
257
+ !this.runState.finalResponseDelivered) {
258
+ try {
259
+ await ctx.replaceMessage("_Sorry, something went wrong_");
260
+ }
261
+ catch (err) {
262
+ const errMsg = err instanceof Error ? err.message : String(err);
263
+ log.logWarning("Failed to post error message", errMsg);
264
+ }
265
+ }
266
+ else if (isSilentOutcome(finalOutcome)) {
267
+ try {
268
+ await ctx.deleteMessage();
269
+ log.logInfo("Silent response - deleted message");
270
+ }
271
+ catch (err) {
272
+ const errMsg = err instanceof Error ? err.message : String(err);
273
+ log.logWarning("Failed to delete message for silent response", errMsg);
274
+ }
275
+ }
276
+ else if (finalOutcomeText && !this.runState.finalResponseDelivered) {
277
+ try {
278
+ await ctx.replaceMessage(finalOutcomeText);
279
+ }
280
+ catch (err) {
281
+ const errMsg = err instanceof Error ? err.message : String(err);
282
+ log.logWarning("Failed to replace message with final text", errMsg);
283
+ }
284
+ }
285
+ await ctx.flush();
286
+ }
287
+ finally {
288
+ await ctx.close();
289
+ }
290
+ // Log usage summary
291
+ if (this.runState.totalUsage.cost.total > 0) {
292
+ const messages = this.session.messages;
293
+ const lastAssistantMessage = messages
294
+ .slice()
295
+ .reverse()
296
+ .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
297
+ const contextTokens = lastAssistantMessage
298
+ ? lastAssistantMessage.usage.input +
299
+ lastAssistantMessage.usage.output +
300
+ lastAssistantMessage.usage.cacheRead +
301
+ lastAssistantMessage.usage.cacheWrite
302
+ : 0;
303
+ const currentRunModel = this.session.model ?? this.activeModel;
304
+ const contextWindow = currentRunModel.contextWindow || 200000;
305
+ log.logUsageSummary(this.runState.logCtx, this.runState.totalUsage, contextTokens, contextWindow);
306
+ }
307
+ // Clear run state
308
+ this.runState.ctx = null;
309
+ this.runState.logCtx = null;
310
+ this.runState.queue = null;
583
311
  }
584
- await sendCommandReply(ctx, `未找到模型 \`${args.trim()}\`。请使用精确的 \`provider/modelId\` 或唯一的 \`modelId\`。
585
-
586
- Available models:
587
- ${available}`);
588
- };
589
- const handleBuiltInCommand = async (ctx, command) => {
312
+ return { stopReason: this.runState.stopReason, errorMessage: this.runState.errorMessage };
313
+ }
314
+ async handleBuiltinCommand(ctx, command) {
590
315
  try {
591
316
  switch (command.name) {
592
317
  case "help":
593
- await sendCommandReply(ctx, renderBuiltInHelp());
318
+ await this.sendCommandReply(ctx, renderBuiltInHelp());
594
319
  return;
595
320
  case "new": {
596
- const completed = await session.newSession();
597
- await sendCommandReply(ctx, completed
598
- ? `已开启新会话。
599
-
600
- Session ID: \`${session.sessionId}\``
601
- : "新会话已取消。");
321
+ const completed = await this.session.newSession();
322
+ await this.sendCommandReply(ctx, completed ? `已开启新会话。\n\nSession ID: \`${this.session.sessionId}\`` : "新会话已取消。");
602
323
  return;
603
324
  }
604
325
  case "compact": {
605
- const result = await session.compact(command.args || undefined);
606
- await sendCommandReply(ctx, `已压缩当前会话上下文。
607
-
608
- - Tokens before compaction: \`${result.tokensBefore}\`
609
- - Summary:
610
-
611
- \`\`\`text
612
- ${result.summary}
613
- \`\`\``);
326
+ const result = await this.session.compact(command.args || undefined);
327
+ await this.sendCommandReply(ctx, `已压缩当前会话上下文。\n\n- Tokens before compaction: \`${result.tokensBefore}\`\n- Summary:\n\n\`\`\`text\n${result.summary}\n\`\`\``);
614
328
  return;
615
329
  }
616
330
  case "session": {
617
- const stats = session.getSessionStats();
618
- const currentModel = session.model ? `\`${formatModelReference(session.model)}\`` : "(none)";
331
+ const stats = this.session.getSessionStats();
332
+ const currentModel = this.session.model ? `\`${formatModelReference(this.session.model)}\`` : "(none)";
619
333
  const sessionFile = stats.sessionFile ? `\`${basename(stats.sessionFile)}\`` : "(none)";
620
- await sendCommandReply(ctx, `# Session
621
-
622
- - Session ID: \`${stats.sessionId}\`
623
- - Session file: ${sessionFile}
624
- - Model: ${currentModel}
625
- - Thinking level: \`${session.thinkingLevel}\`
626
- - User messages: \`${stats.userMessages}\`
627
- - Assistant messages: \`${stats.assistantMessages}\`
628
- - Tool calls: \`${stats.toolCalls}\`
629
- - Tool results: \`${stats.toolResults}\`
630
- - Total messages: \`${stats.totalMessages}\`
631
- - Tokens: \`${stats.tokens.total}\` (input \`${stats.tokens.input}\`, output \`${stats.tokens.output}\`, cache read \`${stats.tokens.cacheRead}\`, cache write \`${stats.tokens.cacheWrite}\`)
632
- - Cost: \`$${stats.cost.toFixed(4)}\``);
334
+ await this.sendCommandReply(ctx, `# Session\n\n- Session ID: \`${stats.sessionId}\`\n- Session file: ${sessionFile}\n- Model: ${currentModel}\n- Thinking level: \`${this.session.thinkingLevel}\`\n- User messages: \`${stats.userMessages}\`\n- Assistant messages: \`${stats.assistantMessages}\`\n- Tool calls: \`${stats.toolCalls}\`\n- Tool results: \`${stats.toolResults}\`\n- Total messages: \`${stats.totalMessages}\`\n- Tokens: \`${stats.tokens.total}\` (input \`${stats.tokens.input}\`, output \`${stats.tokens.output}\`, cache read \`${stats.tokens.cacheRead}\`, cache write \`${stats.tokens.cacheWrite}\`)\n- Cost: \`$${stats.cost.toFixed(4)}\``);
633
335
  return;
634
336
  }
635
337
  case "model":
636
- await handleModelBuiltinCommand(ctx, command.args);
338
+ await this.handleModelCommand(ctx, command.args);
637
339
  return;
638
340
  case "stop":
639
- await sendCommandReply(ctx, "No task is running. Use `/stop` only while a task is running.");
341
+ await this.sendCommandReply(ctx, "No task is running. Use `/stop` only while a task is running.");
640
342
  return;
641
343
  case "steer":
642
- requireQueuedMessage(command.args, "steer");
643
- await sendCommandReply(ctx, "No task is running. Send the message directly instead of using `/steer`.");
344
+ this.requireQueuedMessage(command.args, "steer");
345
+ await this.sendCommandReply(ctx, "No task is running. Send the message directly instead of using `/steer`.");
644
346
  return;
645
347
  case "followup":
646
- requireQueuedMessage(command.args, "followup");
647
- await sendCommandReply(ctx, "No task is running. Send the message directly now, or use `/followup` while a task is running.");
348
+ this.requireQueuedMessage(command.args, "followup");
349
+ await this.sendCommandReply(ctx, "No task is running. Send the message directly now, or use `/followup` while a task is running.");
648
350
  return;
649
351
  }
650
352
  }
651
353
  catch (err) {
652
354
  const errMsg = err instanceof Error ? err.message : String(err);
653
- log.logWarning(`[${channelId}] Built-in command failed`, errMsg);
654
- await sendCommandReply(ctx, `命令执行失败:${errMsg}`);
355
+ log.logWarning(`[${this.channelId}] Built-in command failed`, errMsg);
356
+ await this.sendCommandReply(ctx, `命令执行失败:${errMsg}`);
655
357
  }
656
- };
657
- // Subscribe to events ONCE
658
- session.subscribe(async (event) => {
659
- if (!runState.ctx || !runState.logCtx || !runState.queue)
660
- return;
661
- const { ctx, logCtx, queue, pendingTools } = runState;
662
- if (event.type === "tool_execution_start") {
663
- const agentEvent = event;
664
- const args = agentEvent.args;
665
- const label = args.label || agentEvent.toolName;
666
- pendingTools.set(agentEvent.toolCallId, {
667
- toolName: agentEvent.toolName,
668
- args: agentEvent.args,
669
- startTime: Date.now(),
670
- });
671
- log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
672
- queue.enqueue(() => ctx.respond(formatProgressEntry("tool", label), false), "tool label");
358
+ }
359
+ async queueSteer(text) {
360
+ await this.queueBusyMessage("steer", this.requireQueuedMessage(text, "steer"));
361
+ }
362
+ async queueFollowUp(text) {
363
+ await this.queueBusyMessage("followUp", this.requireQueuedMessage(text, "followup"));
364
+ }
365
+ abort() {
366
+ this.session.abort();
367
+ }
368
+ // === Private helpers ===
369
+ async sendCommandReply(ctx, text) {
370
+ const delivered = await ctx.respondPlain(text);
371
+ if (!delivered) {
372
+ await ctx.replaceMessage(text);
373
+ await ctx.flush();
673
374
  }
674
- else if (event.type === "tool_execution_end") {
675
- const agentEvent = event;
676
- const resultStr = extractToolResultText(agentEvent.result);
677
- const pending = pendingTools.get(agentEvent.toolCallId);
678
- pendingTools.delete(agentEvent.toolCallId);
679
- const durationMs = pending ? Date.now() - pending.startTime : 0;
680
- if (agentEvent.isError) {
681
- log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
682
- }
683
- else {
684
- log.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);
685
- }
686
- if (agentEvent.isError) {
687
- queue.enqueue(() => ctx.respond(formatProgressEntry("error", truncate(resultStr, 200)), false), "tool error");
688
- }
375
+ }
376
+ requireQueuedMessage(text, commandName) {
377
+ const trimmedText = text.trim();
378
+ if (!trimmedText) {
379
+ throw new Error(`/${commandName} requires a message.`);
689
380
  }
690
- else if (event.type === "message_start") {
691
- const agentEvent = event;
692
- if (agentEvent.message.role === "assistant") {
693
- log.logResponseStart(logCtx);
694
- }
381
+ return trimmedText;
382
+ }
383
+ async queueBusyMessage(delivery, text) {
384
+ if (!this.session.isStreaming) {
385
+ throw new Error("No task is currently running.");
695
386
  }
696
- else if (event.type === "message_end") {
697
- const agentEvent = event;
698
- if (agentEvent.message.role === "assistant") {
699
- const assistantMsg = agentEvent.message;
700
- if (assistantMsg.stopReason) {
701
- runState.stopReason = assistantMsg.stopReason;
702
- }
703
- if (assistantMsg.errorMessage) {
704
- runState.errorMessage = assistantMsg.errorMessage;
705
- }
706
- if (assistantMsg.usage) {
707
- runState.totalUsage.input += assistantMsg.usage.input;
708
- runState.totalUsage.output += assistantMsg.usage.output;
709
- runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;
710
- runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;
711
- runState.totalUsage.cost.input += assistantMsg.usage.cost.input;
712
- runState.totalUsage.cost.output += assistantMsg.usage.cost.output;
713
- runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
714
- runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
715
- runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
716
- }
717
- const content = agentEvent.message.content;
718
- const thinkingParts = [];
719
- const textParts = [];
720
- let hasToolCalls = false;
721
- for (const part of content) {
722
- if (part.type === "thinking") {
723
- thinkingParts.push(part.thinking);
724
- }
725
- else if (part.type === "text") {
726
- textParts.push(part.text);
727
- }
728
- else if (part.type === "toolCall") {
729
- hasToolCalls = true;
730
- }
731
- }
732
- const text = textParts.join("\n");
733
- for (const thinking of thinkingParts) {
734
- log.logThinking(logCtx, thinking);
735
- queue.enqueue(() => ctx.respond(formatProgressEntry("thinking", thinking), false), "thinking");
736
- }
737
- if (hasToolCalls && text.trim()) {
738
- queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", text), false), "assistant progress");
739
- }
740
- }
387
+ if (delivery === "followUp") {
388
+ await this.session.followUp(text);
741
389
  }
742
- else if (event.type === "turn_end") {
743
- const turnEvent = event;
744
- if (turnEvent.message.role === "assistant" && turnEvent.toolResults.length === 0) {
745
- if (turnEvent.message.stopReason === "error" || turnEvent.message.stopReason === "aborted") {
746
- return;
747
- }
748
- const finalContent = turnEvent.message.content;
749
- const finalText = finalContent
750
- .filter((part) => part.type === "text" && !!part.text)
751
- .map((part) => part.text)
752
- .join("\n");
753
- const trimmedFinalText = finalText.trim();
754
- if (!trimmedFinalText) {
755
- return;
756
- }
757
- if (trimmedFinalText === "[SILENT]" || trimmedFinalText.startsWith("[SILENT]")) {
758
- runState.finalOutcome = { kind: "silent" };
759
- return;
760
- }
761
- if (runState.finalOutcome.kind === "final" && runState.finalOutcome.text.trim() === trimmedFinalText) {
762
- return;
763
- }
764
- runState.finalOutcome = { kind: "final", text: finalText };
765
- log.logResponse(logCtx, finalText);
766
- queue.enqueue(async () => {
767
- const delivered = await ctx.respondPlain(finalText);
768
- if (delivered) {
769
- runState.finalResponseDelivered = true;
770
- }
771
- }, "final response");
772
- }
390
+ else {
391
+ await this.session.steer(text);
773
392
  }
774
- else if (event.type === "auto_compaction_start") {
775
- log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
776
- queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", "Compacting context..."), false), "compaction start");
393
+ }
394
+ async handleModelCommand(ctx, args) {
395
+ this.modelRegistry.refresh();
396
+ const availableModels = await this.modelRegistry.getAvailable();
397
+ const currentModel = this.session.model;
398
+ if (!args.trim()) {
399
+ const current = currentModel ? `\`${formatModelReference(currentModel)}\`` : "(none)";
400
+ const available = availableModels.length > 0 ? formatModelList(availableModels, currentModel) : "- (none)";
401
+ await this.sendCommandReply(ctx, `# Model\n\nCurrent model: ${current}\n\nUse \`/model <provider/modelId>\` or \`/model <modelId>\` to switch. Bare model IDs must resolve uniquely.\n\nAvailable models:\n${available}`);
402
+ return;
777
403
  }
778
- else if (event.type === "auto_compaction_end") {
779
- const compEvent = event;
780
- if (compEvent.result) {
781
- log.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);
782
- }
783
- else if (compEvent.aborted) {
784
- log.logInfo("Auto-compaction aborted");
785
- }
404
+ const match = findExactModelReferenceMatch(args, availableModels);
405
+ if (match.match) {
406
+ await this.session.setModel(match.match);
407
+ this.activeModel = match.match;
408
+ await this.sendCommandReply(ctx, `已切换模型到 \`${formatModelReference(match.match)}\`。`);
409
+ return;
786
410
  }
787
- else if (event.type === "auto_retry_start") {
788
- const retryEvent = event;
789
- log.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);
790
- queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`), false), "retry");
411
+ const available = availableModels.length > 0 ? formatModelList(availableModels, currentModel, 10) : "- (none)";
412
+ if (match.ambiguous) {
413
+ await this.sendCommandReply(ctx, `未切换模型:\`${args.trim()}\` 匹配到多个模型。请改用精确的 \`provider/modelId\` 形式。\n\nAvailable models:\n${available}`);
414
+ return;
791
415
  }
792
- });
793
- return {
794
- async handleBuiltinCommand(ctx, command) {
795
- await handleBuiltInCommand(ctx, command);
796
- },
797
- async queueSteer(text) {
798
- await queueBusyMessage("steer", requireQueuedMessage(text, "steer"));
799
- },
800
- async queueFollowUp(text) {
801
- await queueBusyMessage("followUp", requireQueuedMessage(text, "followup"));
802
- },
803
- async run(ctx, _store) {
804
- // Reset per-run state
805
- runState.ctx = ctx;
806
- runState.logCtx = {
807
- channelId: ctx.message.channel,
808
- userName: ctx.message.userName,
809
- channelName: ctx.channelName,
810
- };
811
- runState.pendingTools.clear();
812
- runState.totalUsage = {
813
- input: 0,
814
- output: 0,
815
- cacheRead: 0,
816
- cacheWrite: 0,
817
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
818
- };
819
- runState.stopReason = "stop";
820
- runState.errorMessage = undefined;
821
- runState.finalOutcome = { kind: "none" };
822
- runState.finalResponseDelivered = false;
823
- // Create queue for this run
824
- let queueChain = Promise.resolve();
825
- runState.queue = {
826
- enqueue(fn, errorContext) {
827
- queueChain = queueChain.then(async () => {
828
- try {
829
- await fn();
830
- }
831
- catch (err) {
832
- const errMsg = err instanceof Error ? err.message : String(err);
833
- log.logWarning(`DingTalk API error (${errorContext})`, errMsg);
834
- }
835
- });
836
- },
837
- enqueueMessage(text, target, errorContext, doLog = true) {
838
- this.enqueue(() => (target === "main" ? ctx.respond(text, doLog) : ctx.respondInThread(text)), errorContext);
839
- },
840
- };
841
- try {
842
- // Ensure channel directory exists
843
- await mkdir(channelDir, { recursive: true });
844
- // Update system prompt and runtime resources with fresh config
845
- const soul = getSoul(workspaceDir);
846
- const agentConfig = getAgentConfig(channelDir);
847
- const memory = getMemory(channelDir);
848
- const skills = loadPipiclawSkills(channelDir, workspacePath);
849
- currentSkills = skills;
850
- const systemPrompt = buildSystemPrompt(workspacePath, channelId, soul, agentConfig, memory, sandboxConfig, skills);
851
- session.agent.setSystemPrompt(systemPrompt);
852
- await session.reload();
853
- // Sync messages from log.jsonl
854
- const syncedCount = syncLogToSessionManager(sessionManager, channelDir, ctx.message.ts);
855
- if (syncedCount > 0) {
856
- log.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);
416
+ await this.sendCommandReply(ctx, `未找到模型 \`${args.trim()}\`。请使用精确的 \`provider/modelId\` 或唯一的 \`modelId\`。\n\nAvailable models:\n${available}`);
417
+ }
418
+ resetRunState(ctx) {
419
+ this.runState = createEmptyRunState();
420
+ this.runState.ctx = ctx;
421
+ this.runState.logCtx = {
422
+ channelId: ctx.message.channel,
423
+ userName: ctx.message.userName,
424
+ channelName: ctx.channelName,
425
+ };
426
+ }
427
+ // === Session event subscription ===
428
+ subscribeToSessionEvents() {
429
+ this.session.subscribe(async (event) => {
430
+ if (!this.runState.ctx || !this.runState.logCtx || !this.runState.queue)
431
+ return;
432
+ const { ctx, logCtx, queue, pendingTools } = this.runState;
433
+ if (event.type === "tool_execution_start") {
434
+ const agentEvent = event;
435
+ const args = agentEvent.args;
436
+ const label = args.label || agentEvent.toolName;
437
+ pendingTools.set(agentEvent.toolCallId, {
438
+ toolName: agentEvent.toolName,
439
+ args: agentEvent.args,
440
+ startTime: Date.now(),
441
+ });
442
+ log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
443
+ queue.enqueue(() => ctx.respond(formatProgressEntry("tool", label), false), "tool label");
444
+ }
445
+ else if (event.type === "tool_execution_end") {
446
+ const agentEvent = event;
447
+ const resultStr = extractToolResultText(agentEvent.result);
448
+ const pending = pendingTools.get(agentEvent.toolCallId);
449
+ pendingTools.delete(agentEvent.toolCallId);
450
+ const durationMs = pending ? Date.now() - pending.startTime : 0;
451
+ if (agentEvent.isError) {
452
+ log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
857
453
  }
858
- // Reload messages from context.jsonl
859
- const reloadedSession = sessionManager.buildSessionContext();
860
- if (reloadedSession.messages.length > 0) {
861
- agent.replaceMessages(reloadedSession.messages);
862
- log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
454
+ else {
455
+ log.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);
863
456
  }
864
- // Log context info
865
- log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
866
- // Build user message with timestamp and username prefix
867
- const now = new Date();
868
- const pad = (n) => n.toString().padStart(2, "0");
869
- const offset = -now.getTimezoneOffset();
870
- const offsetSign = offset >= 0 ? "+" : "-";
871
- const offsetHours = pad(Math.floor(Math.abs(offset) / 60));
872
- const offsetMins = pad(Math.abs(offset) % 60);
873
- const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;
874
- const userMessage = `[${timestamp}] [${ctx.message.userName || "unknown"}]: ${ctx.message.text}`;
875
- // Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)
876
- if (process.env.PIPICLAW_DEBUG) {
877
- const debugContext = {
878
- systemPrompt,
879
- messages: session.messages,
880
- newUserMessage: userMessage,
881
- };
882
- await writeFile(join(channelDir, "last_prompt.json"), JSON.stringify(debugContext, null, 2));
457
+ if (agentEvent.isError) {
458
+ queue.enqueue(() => ctx.respond(formatProgressEntry("error", truncate(resultStr, 200)), false), "tool error");
883
459
  }
884
- await session.prompt(userMessage);
885
460
  }
886
- catch (err) {
887
- runState.stopReason = "error";
888
- runState.errorMessage = err instanceof Error ? err.message : String(err);
889
- log.logWarning(`[${channelId}] Runner failed`, runState.errorMessage);
461
+ else if (event.type === "message_start") {
462
+ const agentEvent = event;
463
+ if (agentEvent.message.role === "assistant") {
464
+ log.logResponseStart(logCtx);
465
+ }
890
466
  }
891
- finally {
892
- await queueChain;
893
- const finalOutcome = runState.finalOutcome;
894
- const finalOutcomeText = getFinalOutcomeText(finalOutcome);
895
- try {
896
- if (runState.stopReason === "error" && runState.errorMessage && !runState.finalResponseDelivered) {
897
- try {
898
- await ctx.replaceMessage("_Sorry, something went wrong_");
899
- }
900
- catch (err) {
901
- const errMsg = err instanceof Error ? err.message : String(err);
902
- log.logWarning("Failed to post error message", errMsg);
903
- }
467
+ else if (event.type === "message_end") {
468
+ const agentEvent = event;
469
+ if (agentEvent.message.role === "assistant") {
470
+ const assistantMsg = agentEvent.message;
471
+ if (assistantMsg.stopReason) {
472
+ this.runState.stopReason = assistantMsg.stopReason;
904
473
  }
905
- else if (isSilentOutcome(finalOutcome)) {
906
- try {
907
- await ctx.deleteMessage();
908
- log.logInfo("Silent response - deleted message");
909
- }
910
- catch (err) {
911
- const errMsg = err instanceof Error ? err.message : String(err);
912
- log.logWarning("Failed to delete message for silent response", errMsg);
913
- }
474
+ if (assistantMsg.errorMessage) {
475
+ this.runState.errorMessage = assistantMsg.errorMessage;
476
+ }
477
+ if (assistantMsg.usage) {
478
+ this.runState.totalUsage.input += assistantMsg.usage.input;
479
+ this.runState.totalUsage.output += assistantMsg.usage.output;
480
+ this.runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;
481
+ this.runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;
482
+ this.runState.totalUsage.cost.input += assistantMsg.usage.cost.input;
483
+ this.runState.totalUsage.cost.output += assistantMsg.usage.cost.output;
484
+ this.runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
485
+ this.runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
486
+ this.runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
914
487
  }
915
- else if (finalOutcomeText && !runState.finalResponseDelivered) {
916
- try {
917
- await ctx.replaceMessage(finalOutcomeText);
488
+ const content = agentEvent.message.content;
489
+ const thinkingParts = [];
490
+ const textParts = [];
491
+ let hasToolCalls = false;
492
+ for (const part of content) {
493
+ if (part.type === "thinking") {
494
+ thinkingParts.push(part.thinking);
495
+ }
496
+ else if (part.type === "text") {
497
+ textParts.push(part.text);
918
498
  }
919
- catch (err) {
920
- const errMsg = err instanceof Error ? err.message : String(err);
921
- log.logWarning("Failed to replace message with final text", errMsg);
499
+ else if (part.type === "toolCall") {
500
+ hasToolCalls = true;
922
501
  }
923
502
  }
924
- await ctx.flush();
503
+ const text = textParts.join("\n");
504
+ for (const thinking of thinkingParts) {
505
+ log.logThinking(logCtx, thinking);
506
+ queue.enqueue(() => ctx.respond(formatProgressEntry("thinking", thinking), false), "thinking");
507
+ }
508
+ if (hasToolCalls && text.trim()) {
509
+ queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", text), false), "assistant progress");
510
+ }
511
+ }
512
+ }
513
+ else if (event.type === "turn_end") {
514
+ const turnEvent = event;
515
+ if (turnEvent.message.role === "assistant" && turnEvent.toolResults.length === 0) {
516
+ if (turnEvent.message.stopReason === "error" || turnEvent.message.stopReason === "aborted") {
517
+ return;
518
+ }
519
+ const finalContent = turnEvent.message.content;
520
+ const finalText = finalContent
521
+ .filter((part) => part.type === "text" && !!part.text)
522
+ .map((part) => part.text)
523
+ .join("\n");
524
+ const trimmedFinalText = finalText.trim();
525
+ if (!trimmedFinalText) {
526
+ return;
527
+ }
528
+ if (trimmedFinalText === "[SILENT]" || trimmedFinalText.startsWith("[SILENT]")) {
529
+ this.runState.finalOutcome = { kind: "silent" };
530
+ return;
531
+ }
532
+ if (this.runState.finalOutcome.kind === "final" &&
533
+ this.runState.finalOutcome.text.trim() === trimmedFinalText) {
534
+ return;
535
+ }
536
+ this.runState.finalOutcome = { kind: "final", text: finalText };
537
+ log.logResponse(logCtx, finalText);
538
+ queue.enqueue(async () => {
539
+ const delivered = await ctx.respondPlain(finalText);
540
+ if (delivered) {
541
+ this.runState.finalResponseDelivered = true;
542
+ }
543
+ }, "final response");
925
544
  }
926
- finally {
927
- await ctx.close();
545
+ }
546
+ else if (event.type === "auto_compaction_start") {
547
+ log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
548
+ queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", "Compacting context..."), false), "compaction start");
549
+ }
550
+ else if (event.type === "auto_compaction_end") {
551
+ const compEvent = event;
552
+ if (compEvent.result) {
553
+ log.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);
928
554
  }
929
- // Log usage summary
930
- if (runState.totalUsage.cost.total > 0) {
931
- const messages = session.messages;
932
- const lastAssistantMessage = messages
933
- .slice()
934
- .reverse()
935
- .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
936
- const contextTokens = lastAssistantMessage
937
- ? lastAssistantMessage.usage.input +
938
- lastAssistantMessage.usage.output +
939
- lastAssistantMessage.usage.cacheRead +
940
- lastAssistantMessage.usage.cacheWrite
941
- : 0;
942
- const currentRunModel = session.model ?? activeModel;
943
- const contextWindow = currentRunModel.contextWindow || 200000;
944
- log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
555
+ else if (compEvent.aborted) {
556
+ log.logInfo("Auto-compaction aborted");
945
557
  }
946
- // Clear run state
947
- runState.ctx = null;
948
- runState.logCtx = null;
949
- runState.queue = null;
950
558
  }
951
- return { stopReason: runState.stopReason, errorMessage: runState.errorMessage };
952
- },
953
- abort() {
954
- session.abort();
955
- },
956
- };
957
- }
958
- /**
959
- * Translate container path back to host path for file operations
960
- */
961
- export function translateToHostPath(containerPath, channelDir, workspacePath, channelId) {
962
- if (workspacePath === "/workspace") {
963
- const prefix = `/workspace/${channelId}/`;
964
- if (containerPath.startsWith(prefix)) {
965
- return join(channelDir, containerPath.slice(prefix.length));
966
- }
967
- if (containerPath.startsWith("/workspace/")) {
968
- return join(channelDir, "..", containerPath.slice("/workspace/".length));
969
- }
559
+ else if (event.type === "auto_retry_start") {
560
+ const retryEvent = event;
561
+ log.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);
562
+ queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`), false), "retry");
563
+ }
564
+ });
970
565
  }
971
- return containerPath;
566
+ }
567
+ // ============================================================================
568
+ // Factory
569
+ // ============================================================================
570
+ const channelRunners = new Map();
571
+ export function getOrCreateRunner(sandboxConfig, channelId, channelDir) {
572
+ const existing = channelRunners.get(channelId);
573
+ if (existing)
574
+ return existing;
575
+ const runner = new ChannelRunner(sandboxConfig, channelId, channelDir);
576
+ channelRunners.set(channelId, runner);
577
+ return runner;
972
578
  }
973
579
  //# sourceMappingURL=agent.js.map