@oyasmi/pipiclaw 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +247 -0
  3. package/dist/agent.d.ts +18 -0
  4. package/dist/agent.d.ts.map +1 -0
  5. package/dist/agent.js +938 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/commands.d.ts +9 -0
  8. package/dist/commands.d.ts.map +1 -0
  9. package/dist/commands.js +45 -0
  10. package/dist/commands.js.map +1 -0
  11. package/dist/context.d.ts +139 -0
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +432 -0
  14. package/dist/context.js.map +1 -0
  15. package/dist/delivery.d.ts +4 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +221 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/dingtalk.d.ts +109 -0
  20. package/dist/dingtalk.d.ts.map +1 -0
  21. package/dist/dingtalk.js +655 -0
  22. package/dist/dingtalk.js.map +1 -0
  23. package/dist/events.d.ts +51 -0
  24. package/dist/events.d.ts.map +1 -0
  25. package/dist/events.js +287 -0
  26. package/dist/events.js.map +1 -0
  27. package/dist/log.d.ts +33 -0
  28. package/dist/log.d.ts.map +1 -0
  29. package/dist/log.js +188 -0
  30. package/dist/log.js.map +1 -0
  31. package/dist/main.d.ts +3 -0
  32. package/dist/main.d.ts.map +1 -0
  33. package/dist/main.js +298 -0
  34. package/dist/main.js.map +1 -0
  35. package/dist/paths.d.ts +8 -0
  36. package/dist/paths.d.ts.map +1 -0
  37. package/dist/paths.js +10 -0
  38. package/dist/paths.js.map +1 -0
  39. package/dist/sandbox.d.ts +34 -0
  40. package/dist/sandbox.d.ts.map +1 -0
  41. package/dist/sandbox.js +180 -0
  42. package/dist/sandbox.js.map +1 -0
  43. package/dist/shell-escape.d.ts +6 -0
  44. package/dist/shell-escape.d.ts.map +1 -0
  45. package/dist/shell-escape.js +8 -0
  46. package/dist/shell-escape.js.map +1 -0
  47. package/dist/store.d.ts +41 -0
  48. package/dist/store.d.ts.map +1 -0
  49. package/dist/store.js +110 -0
  50. package/dist/store.js.map +1 -0
  51. package/dist/tools/attach.d.ts +14 -0
  52. package/dist/tools/attach.d.ts.map +1 -0
  53. package/dist/tools/attach.js +35 -0
  54. package/dist/tools/attach.js.map +1 -0
  55. package/dist/tools/bash.d.ts +10 -0
  56. package/dist/tools/bash.d.ts.map +1 -0
  57. package/dist/tools/bash.js +78 -0
  58. package/dist/tools/bash.js.map +1 -0
  59. package/dist/tools/edit.d.ts +11 -0
  60. package/dist/tools/edit.d.ts.map +1 -0
  61. package/dist/tools/edit.js +129 -0
  62. package/dist/tools/edit.js.map +1 -0
  63. package/dist/tools/index.d.ts +5 -0
  64. package/dist/tools/index.d.ts.map +1 -0
  65. package/dist/tools/index.js +15 -0
  66. package/dist/tools/index.js.map +1 -0
  67. package/dist/tools/read.d.ts +11 -0
  68. package/dist/tools/read.d.ts.map +1 -0
  69. package/dist/tools/read.js +132 -0
  70. package/dist/tools/read.js.map +1 -0
  71. package/dist/tools/truncate.d.ts +57 -0
  72. package/dist/tools/truncate.d.ts.map +1 -0
  73. package/dist/tools/truncate.js +184 -0
  74. package/dist/tools/truncate.js.map +1 -0
  75. package/dist/tools/write.d.ts +10 -0
  76. package/dist/tools/write.d.ts.map +1 -0
  77. package/dist/tools/write.js +31 -0
  78. package/dist/tools/write.js.map +1 -0
  79. package/package.json +54 -0
package/dist/agent.js ADDED
@@ -0,0 +1,938 @@
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";
5
+ import { mkdir, writeFile } from "fs/promises";
6
+ import { basename, join } from "path";
7
+ import { renderBuiltInHelp } from "./commands.js";
8
+ import { PipiclawSettingsManager, syncLogToSessionManager } from "./context.js";
9
+ import * as log from "./log.js";
10
+ import { APP_HOME_DIR, AUTH_CONFIG_PATH, MODELS_CONFIG_PATH } from "./paths.js";
11
+ import { createExecutor } from "./sandbox.js";
12
+ 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
+ function isSilentOutcome(outcome) {
16
+ return outcome.kind === "silent";
17
+ }
18
+ function isFinalOutcome(outcome) {
19
+ return outcome.kind === "final";
20
+ }
21
+ function getFinalOutcomeText(outcome) {
22
+ return isFinalOutcome(outcome) ? outcome.text : null;
23
+ }
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, AGENT.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 AGENT.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 AGENT.md (global)
132
+ const workspaceAgentPath = join(channelDir, "..", "AGENT.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 AGENT.md", `${workspaceAgentPath}: ${error}`);
142
+ }
143
+ }
144
+ // Read channel-specific AGENT.md (overrides/extends global)
145
+ const channelAgentPath = join(channelDir, "AGENT.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 AGENT.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
+ // ============================================================================
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. AGENT.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
+ ├── AGENT.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
+ ├── AGENT.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. AGENT.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
390
+ // ============================================================================
391
+ function truncate(text, maxLen) {
392
+ if (text.length <= maxLen)
393
+ return text;
394
+ return `${text.substring(0, maxLen - 3)}...`;
395
+ }
396
+ function sanitizeProgressText(text) {
397
+ return text
398
+ .replace(/\uFFFC/g, "")
399
+ .replace(/\r/g, "")
400
+ .trim();
401
+ }
402
+ function formatProgressEntry(kind, text) {
403
+ const cleaned = sanitizeProgressText(text);
404
+ if (!cleaned)
405
+ return "";
406
+ const normalized = cleaned.replace(/\n+/g, " ").trim();
407
+ switch (kind) {
408
+ case "tool":
409
+ return `Running: ${normalized}`;
410
+ case "thinking":
411
+ return `Thinking: ${normalized}`;
412
+ case "error":
413
+ return `Error: ${normalized}`;
414
+ case "assistant":
415
+ return normalized;
416
+ }
417
+ }
418
+ function extractToolResultText(result) {
419
+ if (typeof result === "string") {
420
+ return result;
421
+ }
422
+ if (result &&
423
+ typeof result === "object" &&
424
+ "content" in result &&
425
+ Array.isArray(result.content)) {
426
+ const content = result.content;
427
+ const textParts = [];
428
+ for (const part of content) {
429
+ if (part.type === "text" && part.text) {
430
+ textParts.push(part.text);
431
+ }
432
+ }
433
+ if (textParts.length > 0) {
434
+ return textParts.join("\n");
435
+ }
436
+ }
437
+ return JSON.stringify(result);
438
+ }
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 = {
511
+ ctx: null,
512
+ logCtx: null,
513
+ queue: null,
514
+ pendingTools: new Map(),
515
+ totalUsage: {
516
+ input: 0,
517
+ output: 0,
518
+ cacheRead: 0,
519
+ cacheWrite: 0,
520
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
521
+ },
522
+ stopReason: "stop",
523
+ errorMessage: undefined,
524
+ finalOutcome: { kind: "none" },
525
+ finalResponseDelivered: false,
526
+ };
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 handleModelBuiltinCommand = async (ctx, args) => {
535
+ modelRegistry.refresh();
536
+ const availableModels = await modelRegistry.getAvailable();
537
+ const currentModel = session.model;
538
+ if (!args.trim()) {
539
+ const current = currentModel ? `\`${formatModelReference(currentModel)}\`` : "(none)";
540
+ const available = availableModels.length > 0 ? formatModelList(availableModels, currentModel) : "- (none)";
541
+ await sendCommandReply(ctx, `# Model
542
+
543
+ Current model: ${current}
544
+
545
+ Use \`/model <provider/modelId>\` or \`/model <modelId>\` to switch. Bare model IDs must resolve uniquely.
546
+
547
+ Available models:
548
+ ${available}`);
549
+ return;
550
+ }
551
+ const match = findExactModelReferenceMatch(args, availableModels);
552
+ if (match.match) {
553
+ await session.setModel(match.match);
554
+ activeModel = match.match;
555
+ await sendCommandReply(ctx, `已切换模型到 \`${formatModelReference(match.match)}\`。`);
556
+ return;
557
+ }
558
+ const available = availableModels.length > 0 ? formatModelList(availableModels, currentModel, 10) : "- (none)";
559
+ if (match.ambiguous) {
560
+ await sendCommandReply(ctx, `未切换模型:\`${args.trim()}\` 匹配到多个模型。请改用精确的 \`provider/modelId\` 形式。
561
+
562
+ Available models:
563
+ ${available}`);
564
+ return;
565
+ }
566
+ await sendCommandReply(ctx, `未找到模型 \`${args.trim()}\`。请使用精确的 \`provider/modelId\` 或唯一的 \`modelId\`。
567
+
568
+ Available models:
569
+ ${available}`);
570
+ };
571
+ const handleBuiltInCommand = async (ctx, command) => {
572
+ try {
573
+ switch (command.name) {
574
+ case "help":
575
+ await sendCommandReply(ctx, renderBuiltInHelp());
576
+ return;
577
+ case "new": {
578
+ const completed = await session.newSession();
579
+ await sendCommandReply(ctx, completed
580
+ ? `已开启新会话。
581
+
582
+ Session ID: \`${session.sessionId}\``
583
+ : "新会话已取消。");
584
+ return;
585
+ }
586
+ case "compact": {
587
+ const result = await session.compact(command.args || undefined);
588
+ await sendCommandReply(ctx, `已压缩当前会话上下文。
589
+
590
+ - Tokens before compaction: \`${result.tokensBefore}\`
591
+ - Summary:
592
+
593
+ \`\`\`text
594
+ ${result.summary}
595
+ \`\`\``);
596
+ return;
597
+ }
598
+ case "session": {
599
+ const stats = session.getSessionStats();
600
+ const currentModel = session.model ? `\`${formatModelReference(session.model)}\`` : "(none)";
601
+ const sessionFile = stats.sessionFile ? `\`${basename(stats.sessionFile)}\`` : "(none)";
602
+ await sendCommandReply(ctx, `# Session
603
+
604
+ - Session ID: \`${stats.sessionId}\`
605
+ - Session file: ${sessionFile}
606
+ - Model: ${currentModel}
607
+ - Thinking level: \`${session.thinkingLevel}\`
608
+ - User messages: \`${stats.userMessages}\`
609
+ - Assistant messages: \`${stats.assistantMessages}\`
610
+ - Tool calls: \`${stats.toolCalls}\`
611
+ - Tool results: \`${stats.toolResults}\`
612
+ - Total messages: \`${stats.totalMessages}\`
613
+ - Tokens: \`${stats.tokens.total}\` (input \`${stats.tokens.input}\`, output \`${stats.tokens.output}\`, cache read \`${stats.tokens.cacheRead}\`, cache write \`${stats.tokens.cacheWrite}\`)
614
+ - Cost: \`$${stats.cost.toFixed(4)}\``);
615
+ return;
616
+ }
617
+ case "model":
618
+ await handleModelBuiltinCommand(ctx, command.args);
619
+ return;
620
+ }
621
+ }
622
+ catch (err) {
623
+ const errMsg = err instanceof Error ? err.message : String(err);
624
+ log.logWarning(`[${channelId}] Built-in command failed`, errMsg);
625
+ await sendCommandReply(ctx, `命令执行失败:${errMsg}`);
626
+ }
627
+ };
628
+ // Subscribe to events ONCE
629
+ session.subscribe(async (event) => {
630
+ if (!runState.ctx || !runState.logCtx || !runState.queue)
631
+ return;
632
+ const { ctx, logCtx, queue, pendingTools } = runState;
633
+ if (event.type === "tool_execution_start") {
634
+ const agentEvent = event;
635
+ const args = agentEvent.args;
636
+ const label = args.label || agentEvent.toolName;
637
+ pendingTools.set(agentEvent.toolCallId, {
638
+ toolName: agentEvent.toolName,
639
+ args: agentEvent.args,
640
+ startTime: Date.now(),
641
+ });
642
+ log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
643
+ queue.enqueue(() => ctx.respond(formatProgressEntry("tool", label), false), "tool label");
644
+ }
645
+ else if (event.type === "tool_execution_end") {
646
+ const agentEvent = event;
647
+ const resultStr = extractToolResultText(agentEvent.result);
648
+ const pending = pendingTools.get(agentEvent.toolCallId);
649
+ pendingTools.delete(agentEvent.toolCallId);
650
+ const durationMs = pending ? Date.now() - pending.startTime : 0;
651
+ if (agentEvent.isError) {
652
+ log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
653
+ }
654
+ else {
655
+ log.logToolSuccess(logCtx, agentEvent.toolName, durationMs, resultStr);
656
+ }
657
+ if (agentEvent.isError) {
658
+ queue.enqueue(() => ctx.respond(formatProgressEntry("error", truncate(resultStr, 200)), false), "tool error");
659
+ }
660
+ }
661
+ else if (event.type === "message_start") {
662
+ const agentEvent = event;
663
+ if (agentEvent.message.role === "assistant") {
664
+ log.logResponseStart(logCtx);
665
+ }
666
+ }
667
+ else if (event.type === "message_end") {
668
+ const agentEvent = event;
669
+ if (agentEvent.message.role === "assistant") {
670
+ const assistantMsg = agentEvent.message;
671
+ if (assistantMsg.stopReason) {
672
+ runState.stopReason = assistantMsg.stopReason;
673
+ }
674
+ if (assistantMsg.errorMessage) {
675
+ runState.errorMessage = assistantMsg.errorMessage;
676
+ }
677
+ if (assistantMsg.usage) {
678
+ runState.totalUsage.input += assistantMsg.usage.input;
679
+ runState.totalUsage.output += assistantMsg.usage.output;
680
+ runState.totalUsage.cacheRead += assistantMsg.usage.cacheRead;
681
+ runState.totalUsage.cacheWrite += assistantMsg.usage.cacheWrite;
682
+ runState.totalUsage.cost.input += assistantMsg.usage.cost.input;
683
+ runState.totalUsage.cost.output += assistantMsg.usage.cost.output;
684
+ runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
685
+ runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
686
+ runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
687
+ }
688
+ const content = agentEvent.message.content;
689
+ const thinkingParts = [];
690
+ const textParts = [];
691
+ let hasToolCalls = false;
692
+ for (const part of content) {
693
+ if (part.type === "thinking") {
694
+ thinkingParts.push(part.thinking);
695
+ }
696
+ else if (part.type === "text") {
697
+ textParts.push(part.text);
698
+ }
699
+ else if (part.type === "toolCall") {
700
+ hasToolCalls = true;
701
+ }
702
+ }
703
+ const text = textParts.join("\n");
704
+ for (const thinking of thinkingParts) {
705
+ log.logThinking(logCtx, thinking);
706
+ queue.enqueue(() => ctx.respond(formatProgressEntry("thinking", thinking), false), "thinking");
707
+ }
708
+ if (hasToolCalls && text.trim()) {
709
+ queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", text), false), "assistant progress");
710
+ }
711
+ }
712
+ }
713
+ else if (event.type === "turn_end") {
714
+ const turnEvent = event;
715
+ if (turnEvent.message.role === "assistant" && turnEvent.toolResults.length === 0) {
716
+ if (turnEvent.message.stopReason === "error" || turnEvent.message.stopReason === "aborted") {
717
+ return;
718
+ }
719
+ const finalContent = turnEvent.message.content;
720
+ const finalText = finalContent
721
+ .filter((part) => part.type === "text" && !!part.text)
722
+ .map((part) => part.text)
723
+ .join("\n");
724
+ const trimmedFinalText = finalText.trim();
725
+ if (!trimmedFinalText) {
726
+ return;
727
+ }
728
+ if (trimmedFinalText === "[SILENT]" || trimmedFinalText.startsWith("[SILENT]")) {
729
+ runState.finalOutcome = { kind: "silent" };
730
+ return;
731
+ }
732
+ if (runState.finalOutcome.kind === "final" && runState.finalOutcome.text.trim() === trimmedFinalText) {
733
+ return;
734
+ }
735
+ runState.finalOutcome = { kind: "final", text: finalText };
736
+ log.logResponse(logCtx, finalText);
737
+ queue.enqueue(async () => {
738
+ const delivered = await ctx.respondPlain(finalText);
739
+ if (delivered) {
740
+ runState.finalResponseDelivered = true;
741
+ }
742
+ }, "final response");
743
+ }
744
+ }
745
+ else if (event.type === "auto_compaction_start") {
746
+ log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
747
+ queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", "Compacting context..."), false), "compaction start");
748
+ }
749
+ else if (event.type === "auto_compaction_end") {
750
+ const compEvent = event;
751
+ if (compEvent.result) {
752
+ log.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);
753
+ }
754
+ else if (compEvent.aborted) {
755
+ log.logInfo("Auto-compaction aborted");
756
+ }
757
+ }
758
+ else if (event.type === "auto_retry_start") {
759
+ const retryEvent = event;
760
+ log.logWarning(`Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})`, retryEvent.errorMessage);
761
+ queue.enqueue(() => ctx.respond(formatProgressEntry("assistant", `Retrying (${retryEvent.attempt}/${retryEvent.maxAttempts})...`), false), "retry");
762
+ }
763
+ });
764
+ return {
765
+ async handleBuiltinCommand(ctx, command) {
766
+ await handleBuiltInCommand(ctx, command);
767
+ },
768
+ async run(ctx, _store) {
769
+ // Reset per-run state
770
+ runState.ctx = ctx;
771
+ runState.logCtx = {
772
+ channelId: ctx.message.channel,
773
+ userName: ctx.message.userName,
774
+ channelName: ctx.channelName,
775
+ };
776
+ runState.pendingTools.clear();
777
+ runState.totalUsage = {
778
+ input: 0,
779
+ output: 0,
780
+ cacheRead: 0,
781
+ cacheWrite: 0,
782
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
783
+ };
784
+ runState.stopReason = "stop";
785
+ runState.errorMessage = undefined;
786
+ runState.finalOutcome = { kind: "none" };
787
+ runState.finalResponseDelivered = false;
788
+ // Create queue for this run
789
+ let queueChain = Promise.resolve();
790
+ runState.queue = {
791
+ enqueue(fn, errorContext) {
792
+ queueChain = queueChain.then(async () => {
793
+ try {
794
+ await fn();
795
+ }
796
+ catch (err) {
797
+ const errMsg = err instanceof Error ? err.message : String(err);
798
+ log.logWarning(`DingTalk API error (${errorContext})`, errMsg);
799
+ }
800
+ });
801
+ },
802
+ enqueueMessage(text, target, errorContext, doLog = true) {
803
+ this.enqueue(() => (target === "main" ? ctx.respond(text, doLog) : ctx.respondInThread(text)), errorContext);
804
+ },
805
+ };
806
+ try {
807
+ // Ensure channel directory exists
808
+ await mkdir(channelDir, { recursive: true });
809
+ // Update system prompt and runtime resources with fresh config
810
+ const soul = getSoul(workspaceDir);
811
+ const agentConfig = getAgentConfig(channelDir);
812
+ const memory = getMemory(channelDir);
813
+ const skills = loadPipiclawSkills(channelDir, workspacePath);
814
+ currentSkills = skills;
815
+ const systemPrompt = buildSystemPrompt(workspacePath, channelId, soul, agentConfig, memory, sandboxConfig, skills);
816
+ session.agent.setSystemPrompt(systemPrompt);
817
+ await session.reload();
818
+ // Sync messages from log.jsonl
819
+ const syncedCount = syncLogToSessionManager(sessionManager, channelDir, ctx.message.ts);
820
+ if (syncedCount > 0) {
821
+ log.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);
822
+ }
823
+ // Reload messages from context.jsonl
824
+ const reloadedSession = sessionManager.buildSessionContext();
825
+ if (reloadedSession.messages.length > 0) {
826
+ agent.replaceMessages(reloadedSession.messages);
827
+ log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
828
+ }
829
+ // Log context info
830
+ log.logInfo(`Context sizes - system: ${systemPrompt.length} chars, memory: ${memory.length} chars`);
831
+ // Build user message with timestamp and username prefix
832
+ const now = new Date();
833
+ const pad = (n) => n.toString().padStart(2, "0");
834
+ const offset = -now.getTimezoneOffset();
835
+ const offsetSign = offset >= 0 ? "+" : "-";
836
+ const offsetHours = pad(Math.floor(Math.abs(offset) / 60));
837
+ const offsetMins = pad(Math.abs(offset) % 60);
838
+ const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;
839
+ const userMessage = `[${timestamp}] [${ctx.message.userName || "unknown"}]: ${ctx.message.text}`;
840
+ // Debug: write context to last_prompt.json (only with PIPICLAW_DEBUG=1)
841
+ if (process.env.PIPICLAW_DEBUG) {
842
+ const debugContext = {
843
+ systemPrompt,
844
+ messages: session.messages,
845
+ newUserMessage: userMessage,
846
+ };
847
+ await writeFile(join(channelDir, "last_prompt.json"), JSON.stringify(debugContext, null, 2));
848
+ }
849
+ await session.prompt(userMessage);
850
+ }
851
+ catch (err) {
852
+ runState.stopReason = "error";
853
+ runState.errorMessage = err instanceof Error ? err.message : String(err);
854
+ log.logWarning(`[${channelId}] Runner failed`, runState.errorMessage);
855
+ }
856
+ finally {
857
+ await queueChain;
858
+ const finalOutcome = runState.finalOutcome;
859
+ const finalOutcomeText = getFinalOutcomeText(finalOutcome);
860
+ try {
861
+ if (runState.stopReason === "error" && runState.errorMessage && !runState.finalResponseDelivered) {
862
+ try {
863
+ await ctx.replaceMessage("_Sorry, something went wrong_");
864
+ }
865
+ catch (err) {
866
+ const errMsg = err instanceof Error ? err.message : String(err);
867
+ log.logWarning("Failed to post error message", errMsg);
868
+ }
869
+ }
870
+ else if (isSilentOutcome(finalOutcome)) {
871
+ try {
872
+ await ctx.deleteMessage();
873
+ log.logInfo("Silent response - deleted message");
874
+ }
875
+ catch (err) {
876
+ const errMsg = err instanceof Error ? err.message : String(err);
877
+ log.logWarning("Failed to delete message for silent response", errMsg);
878
+ }
879
+ }
880
+ else if (finalOutcomeText && !runState.finalResponseDelivered) {
881
+ try {
882
+ await ctx.replaceMessage(finalOutcomeText);
883
+ }
884
+ catch (err) {
885
+ const errMsg = err instanceof Error ? err.message : String(err);
886
+ log.logWarning("Failed to replace message with final text", errMsg);
887
+ }
888
+ }
889
+ await ctx.flush();
890
+ }
891
+ finally {
892
+ await ctx.close();
893
+ }
894
+ // Log usage summary
895
+ if (runState.totalUsage.cost.total > 0) {
896
+ const messages = session.messages;
897
+ const lastAssistantMessage = messages
898
+ .slice()
899
+ .reverse()
900
+ .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
901
+ const contextTokens = lastAssistantMessage
902
+ ? lastAssistantMessage.usage.input +
903
+ lastAssistantMessage.usage.output +
904
+ lastAssistantMessage.usage.cacheRead +
905
+ lastAssistantMessage.usage.cacheWrite
906
+ : 0;
907
+ const currentRunModel = session.model ?? activeModel;
908
+ const contextWindow = currentRunModel.contextWindow || 200000;
909
+ log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
910
+ }
911
+ // Clear run state
912
+ runState.ctx = null;
913
+ runState.logCtx = null;
914
+ runState.queue = null;
915
+ }
916
+ return { stopReason: runState.stopReason, errorMessage: runState.errorMessage };
917
+ },
918
+ abort() {
919
+ session.abort();
920
+ },
921
+ };
922
+ }
923
+ /**
924
+ * Translate container path back to host path for file operations
925
+ */
926
+ export function translateToHostPath(containerPath, channelDir, workspacePath, channelId) {
927
+ if (workspacePath === "/workspace") {
928
+ const prefix = `/workspace/${channelId}/`;
929
+ if (containerPath.startsWith(prefix)) {
930
+ return join(channelDir, containerPath.slice(prefix.length));
931
+ }
932
+ if (containerPath.startsWith("/workspace/")) {
933
+ return join(channelDir, "..", containerPath.slice("/workspace/".length));
934
+ }
935
+ }
936
+ return containerPath;
937
+ }
938
+ //# sourceMappingURL=agent.js.map