@poncho-ai/harness 0.22.1 → 0.24.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.22.1 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.24.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,8 +8,8 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 260.03 KB
12
- ESM ⚡️ Build success in 131ms
11
+ ESM dist/index.js 263.89 KB
12
+ ESM ⚡️ Build success in 136ms
13
13
  DTS Build start
14
- DTS ⚡️ Build success in 6723ms
15
- DTS dist/index.d.ts 27.15 KB
14
+ DTS ⚡️ Build success in 6918ms
15
+ DTS dist/index.d.ts 27.50 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`aee4f17`](https://github.com/cesr/poncho-ai/commit/aee4f17237d33b2cc134ed9934b709d967ca3f10) Thanks [@cesr](https://github.com/cesr)! - Add `edit_file` built-in tool with str_replace semantics for targeted file edits. The tool takes `path`, `old_str`, and `new_str` parameters, enforces uniqueness of the match, and is write-gated like `write_file` (disabled in production by default). Also improves browser SSE frame streaming with backpressure handling and auto-stops screencast when all listeners disconnect.
8
+
9
+ ## 0.23.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`d1e1bfb`](https://github.com/cesr/poncho-ai/commit/d1e1bfbf35b18788ab79231ca675774e949f5116) Thanks [@cesr](https://github.com/cesr)! - Add proactive scheduled messaging via channel-targeted cron jobs. Cron jobs with `channel: telegram` (or `slack`) now automatically discover known conversations and send the agent's response directly to each chat, continuing the existing conversation history.
14
+
3
15
  ## 0.22.1
4
16
 
5
17
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ interface CronJobConfig {
19
19
  schedule: string;
20
20
  task: string;
21
21
  timezone?: string;
22
+ channel?: string;
22
23
  }
23
24
  interface AgentFrontmatter {
24
25
  name: string;
@@ -155,6 +156,11 @@ interface Conversation {
155
156
  result?: _poncho_ai_sdk.RunResult;
156
157
  error?: _poncho_ai_sdk.AgentFailure;
157
158
  };
159
+ channelMeta?: {
160
+ platform: string;
161
+ channelId: string;
162
+ platformThreadId: string;
163
+ };
158
164
  createdAt: number;
159
165
  updatedAt: number;
160
166
  }
@@ -208,6 +214,11 @@ type ConversationSummary = {
208
214
  parentConversationId?: string;
209
215
  messageCount?: number;
210
216
  hasPendingApprovals?: boolean;
217
+ channelMeta?: {
218
+ platform: string;
219
+ channelId: string;
220
+ platformThreadId: string;
221
+ };
211
222
  };
212
223
  declare const createStateStore: (config?: StateConfig, options?: {
213
224
  workingDir?: string;
@@ -317,6 +328,7 @@ type BuiltInToolToggles = {
317
328
  list_directory?: boolean;
318
329
  read_file?: boolean;
319
330
  write_file?: boolean;
331
+ edit_file?: boolean;
320
332
  delete_file?: boolean;
321
333
  delete_directory?: boolean;
322
334
  };
@@ -423,6 +435,7 @@ declare const loadPonchoConfig: (workingDir: string) => Promise<PonchoConfig | u
423
435
 
424
436
  declare const createDefaultTools: (workingDir: string) => ToolDefinition[];
425
437
  declare const createWriteTool: (workingDir: string) => ToolDefinition;
438
+ declare const createEditTool: (workingDir: string) => ToolDefinition;
426
439
  declare const createDeleteTool: (workingDir: string) => ToolDefinition;
427
440
  declare const createDeleteDirectoryTool: (workingDir: string) => ToolDefinition;
428
441
  declare const ponchoDocsTool: ToolDefinition;
@@ -751,4 +764,4 @@ declare class TelemetryEmitter {
751
764
 
752
765
  declare const createSubagentTools: (manager: SubagentManager, getConversationId: () => string | undefined, getOwnerId: () => string) => ToolDefinition[];
753
766
 
754
- export { type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, type BuiltInToolToggles, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type Conversation, type ConversationState, type ConversationStore, type ConversationSummary, type CronJobConfig, type HarnessOptions, type HarnessRunOutput, InMemoryConversationStore, InMemoryStateStore, LatitudeCapture, type LatitudeCaptureConfig, LocalMcpBridge, LocalUploadStore, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PonchoConfig, type ProviderConfig, type RemoteMcpServerConfig, type RuntimeRenderContext, S3UploadStore, STORAGE_SCHEMA_VERSION, type SkillContextEntry, type SkillMetadata, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type SubagentManager, type SubagentResult, type SubagentSummary, type TelemetryConfig, TelemetryEmitter, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type UploadStore, type UploadsConfig, VercelBlobUploadStore, buildAgentDirectoryName, buildSkillContextWindow, compactMessages, createConversationStore, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createMemoryStore, createMemoryTools, createModelProvider, createSkillTools, createStateStore, createSubagentTools, createUploadStore, createWriteTool, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, findSafeSplitPoint, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getPonchoStoreRoot, jsonSchemaToZod, loadPonchoConfig, loadSkillContext, loadSkillInstructions, loadSkillMetadata, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, ponchoDocsTool, readSkillResource, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveMemoryConfig, resolveSkillDirs, resolveStateConfig, slugifyStorageComponent };
767
+ export { type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, type BuiltInToolToggles, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type Conversation, type ConversationState, type ConversationStore, type ConversationSummary, type CronJobConfig, type HarnessOptions, type HarnessRunOutput, InMemoryConversationStore, InMemoryStateStore, LatitudeCapture, type LatitudeCaptureConfig, LocalMcpBridge, LocalUploadStore, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PonchoConfig, type ProviderConfig, type RemoteMcpServerConfig, type RuntimeRenderContext, S3UploadStore, STORAGE_SCHEMA_VERSION, type SkillContextEntry, type SkillMetadata, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type SubagentManager, type SubagentResult, type SubagentSummary, type TelemetryConfig, TelemetryEmitter, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type UploadStore, type UploadsConfig, VercelBlobUploadStore, buildAgentDirectoryName, buildSkillContextWindow, compactMessages, createConversationStore, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryTools, createModelProvider, createSkillTools, createStateStore, createSubagentTools, createUploadStore, createWriteTool, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, findSafeSplitPoint, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getPonchoStoreRoot, jsonSchemaToZod, loadPonchoConfig, loadSkillContext, loadSkillInstructions, loadSkillMetadata, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, ponchoDocsTool, readSkillResource, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveMemoryConfig, resolveSkillDirs, resolveStateConfig, slugifyStorageComponent };
package/dist/index.js CHANGED
@@ -112,10 +112,12 @@ var parseCronJobs = (value) => {
112
112
  if (timezone) {
113
113
  validateTimezone(timezone, path);
114
114
  }
115
+ const channel = typeof jobValue.channel === "string" && jobValue.channel.trim() ? jobValue.channel.trim() : void 0;
115
116
  jobs[jobName] = {
116
117
  schedule: jobValue.schedule.trim(),
117
118
  task: jobValue.task,
118
- timezone
119
+ timezone,
120
+ channel
119
121
  };
120
122
  }
121
123
  return jobs;
@@ -1030,6 +1032,22 @@ messaging: [
1030
1032
  ]
1031
1033
  \`\`\`
1032
1034
 
1035
+ #### Proactive scheduled messages
1036
+
1037
+ You can have the agent proactively message Telegram chats on a cron schedule. Add \`channel: telegram\` to any cron job in your \`AGENT.md\` frontmatter:
1038
+
1039
+ \`\`\`yaml
1040
+ cron:
1041
+ daily-checkin:
1042
+ schedule: "0 9 * * *"
1043
+ task: "Check in with the user about their plans for today"
1044
+ channel: telegram
1045
+ \`\`\`
1046
+
1047
+ The system auto-discovers all Telegram chats the bot has interacted with and sends the agent's response to each one. No chat IDs need to be configured -- filtering is handled by \`allowedUserIds\` if set. The agent runs with the full conversation history for each chat, so it has context from prior interactions.
1048
+
1049
+ The bot must have received at least one message from a user before it can send proactive messages to that chat (Telegram API requirement).
1050
+
1033
1051
  ### Email (Resend)
1034
1052
 
1035
1053
  #### 1. Set up Resend
@@ -1890,6 +1908,52 @@ var createWriteTool = (workingDir) => defineTool({
1890
1908
  return { path, written: true };
1891
1909
  }
1892
1910
  });
1911
+ var createEditTool = (workingDir) => defineTool({
1912
+ name: "edit_file",
1913
+ description: "Edit a file by replacing an exact string match with new content. The old_str must match exactly one location in the file (including whitespace and indentation). Use an empty new_str to delete matched content.",
1914
+ inputSchema: {
1915
+ type: "object",
1916
+ properties: {
1917
+ path: {
1918
+ type: "string",
1919
+ description: "File path relative to working directory"
1920
+ },
1921
+ old_str: {
1922
+ type: "string",
1923
+ description: "The exact text to find and replace (must be unique in the file). Include surrounding context lines if needed to ensure uniqueness."
1924
+ },
1925
+ new_str: {
1926
+ type: "string",
1927
+ description: "The replacement text (use empty string to delete the matched content)"
1928
+ }
1929
+ },
1930
+ required: ["path", "old_str", "new_str"],
1931
+ additionalProperties: false
1932
+ },
1933
+ handler: async (input) => {
1934
+ const path = typeof input.path === "string" ? input.path : "";
1935
+ const oldStr = typeof input.old_str === "string" ? input.old_str : "";
1936
+ const newStr = typeof input.new_str === "string" ? input.new_str : "";
1937
+ if (!oldStr) throw new Error("old_str must not be empty.");
1938
+ const resolved = resolveSafePath(workingDir, path);
1939
+ const content = await readFile3(resolved, "utf8");
1940
+ const first = content.indexOf(oldStr);
1941
+ if (first === -1) {
1942
+ throw new Error(
1943
+ "old_str not found in file. Make sure it matches exactly, including whitespace and line breaks."
1944
+ );
1945
+ }
1946
+ const last = content.lastIndexOf(oldStr);
1947
+ if (first !== last) {
1948
+ throw new Error(
1949
+ "old_str appears multiple times in the file. Please provide more context to ensure a unique match."
1950
+ );
1951
+ }
1952
+ const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
1953
+ await writeFile2(resolved, newContent, "utf8");
1954
+ return { path, edited: true };
1955
+ }
1956
+ });
1893
1957
  var createDeleteTool = (workingDir) => defineTool({
1894
1958
  name: "delete_file",
1895
1959
  description: "Delete a file at a path inside the working directory",
@@ -4309,7 +4373,7 @@ You are running locally in development mode. Treat this as an editable agent wor
4309
4373
  ## Understanding Your Environment
4310
4374
 
4311
4375
  - Built-in tools: \`list_directory\` and \`read_file\`
4312
- - \`write_file\` is available in development (disabled by default in production)
4376
+ - \`write_file\` and \`edit_file\` are available in development (disabled by default in production)
4313
4377
  - A starter local skill is included (\`starter-echo\`)
4314
4378
  - Bash/shell commands are **not** available unless you install and enable a shell tool/skill
4315
4379
  - Git operations are only available if a git-capable tool/skill is configured
@@ -4365,6 +4429,10 @@ cron:
4365
4429
  schedule: "0 9 * * *" # Standard 5-field cron expression
4366
4430
  timezone: "America/New_York" # Optional IANA timezone (default: UTC)
4367
4431
  task: "Generate the daily sales report"
4432
+ telegram-checkin:
4433
+ schedule: "0 18 * * 1-5"
4434
+ channel: telegram # Proactive message to all known Telegram chats
4435
+ task: "Send an end-of-day summary to the user"
4368
4436
  \`\`\`
4369
4437
 
4370
4438
  - Each cron job triggers an autonomous agent run with the specified task, creating a fresh conversation.
@@ -4373,6 +4441,7 @@ cron:
4373
4441
  - Jobs can also be triggered manually: \`GET /api/cron/<jobName>\`.
4374
4442
  - To carry context across cron runs, enable memory.
4375
4443
  - **IMPORTANT**: When adding a new cron job, always PRESERVE all existing cron jobs. Never remove or overwrite existing jobs unless the user explicitly asks you to replace or delete them. Read the full current \`cron:\` block before editing, and append the new job alongside the existing ones.
4444
+ - **Proactive channel messaging**: Adding \`channel: telegram\` (or \`slack\`) makes the cron job send its response directly to all known conversations on that platform, instead of creating a standalone conversation. The agent continues the existing conversation history for context. A chat must have at least one prior user message for auto-discovery to find it.
4376
4445
 
4377
4446
  ## Messaging Integrations (Slack, Telegram, Email)
4378
4447
 
@@ -4565,6 +4634,7 @@ Since all fields have defaults, you only need to specify \`*Env\` when your env
4565
4634
  - If shell/CLI access is unavailable, ask the user to run needed commands and provide exact copy-paste commands.
4566
4635
  - For setup, skills, MCP, auth, storage, telemetry, or "how do I..." questions, proactively read \`README.md\` with \`read_file\` before answering.
4567
4636
  - Prefer quoting concrete commands and examples from \`README.md\` over guessing.
4637
+ - Prefer \`edit_file\` for targeted changes to existing files (uses exact string matching); use \`write_file\` only for creating new files or full rewrites.
4568
4638
  - Keep edits minimal, preserve unrelated settings/code, and summarize what changed.
4569
4639
 
4570
4640
  ## Detailed Documentation
@@ -4635,7 +4705,7 @@ var AgentHarness = class _AgentHarness {
4635
4705
  isToolEnabled(name) {
4636
4706
  const access3 = this.resolveToolAccess(name);
4637
4707
  if (access3 === false) return false;
4638
- if (name === "write_file" || name === "delete_file" || name === "delete_directory") {
4708
+ if (name === "write_file" || name === "edit_file" || name === "delete_file" || name === "delete_directory") {
4639
4709
  return this.shouldEnableWriteTool();
4640
4710
  }
4641
4711
  return true;
@@ -4678,6 +4748,9 @@ var AgentHarness = class _AgentHarness {
4678
4748
  if (this.isToolEnabled("write_file")) {
4679
4749
  this.registerIfMissing(createWriteTool(this.workingDir));
4680
4750
  }
4751
+ if (this.isToolEnabled("edit_file")) {
4752
+ this.registerIfMissing(createEditTool(this.workingDir));
4753
+ }
4681
4754
  if (this.isToolEnabled("delete_file")) {
4682
4755
  this.registerIfMissing(createDeleteTool(this.workingDir));
4683
4756
  }
@@ -6350,7 +6423,8 @@ var InMemoryConversationStore = class {
6350
6423
  ownerId: c.ownerId,
6351
6424
  parentConversationId: c.parentConversationId,
6352
6425
  messageCount: c.messages.length,
6353
- hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0
6426
+ hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
6427
+ channelMeta: c.channelMeta
6354
6428
  }));
6355
6429
  }
6356
6430
  async get(conversationId) {
@@ -6512,7 +6586,8 @@ var FileConversationStore = class {
6512
6586
  fileName,
6513
6587
  parentConversationId: conversation.parentConversationId,
6514
6588
  messageCount: conversation.messages.length,
6515
- hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0
6589
+ hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0,
6590
+ channelMeta: conversation.channelMeta
6516
6591
  });
6517
6592
  await this.writeIndex();
6518
6593
  });
@@ -6540,7 +6615,8 @@ var FileConversationStore = class {
6540
6615
  ownerId: c.ownerId,
6541
6616
  parentConversationId: c.parentConversationId,
6542
6617
  messageCount: c.messageCount,
6543
- hasPendingApprovals: c.hasPendingApprovals
6618
+ hasPendingApprovals: c.hasPendingApprovals,
6619
+ channelMeta: c.channelMeta
6544
6620
  }));
6545
6621
  }
6546
6622
  async get(conversationId) {
@@ -6806,7 +6882,8 @@ var KeyValueConversationStoreBase = class {
6806
6882
  ownerId: meta.ownerId,
6807
6883
  parentConversationId: meta.parentConversationId,
6808
6884
  messageCount: meta.messageCount,
6809
- hasPendingApprovals: meta.hasPendingApprovals
6885
+ hasPendingApprovals: meta.hasPendingApprovals,
6886
+ channelMeta: meta.channelMeta
6810
6887
  });
6811
6888
  }
6812
6889
  } catch {
@@ -6867,7 +6944,8 @@ var KeyValueConversationStoreBase = class {
6867
6944
  ownerId: nextConversation.ownerId,
6868
6945
  parentConversationId: nextConversation.parentConversationId,
6869
6946
  messageCount: nextConversation.messages.length,
6870
- hasPendingApprovals: Array.isArray(nextConversation.pendingApprovals) && nextConversation.pendingApprovals.length > 0
6947
+ hasPendingApprovals: Array.isArray(nextConversation.pendingApprovals) && nextConversation.pendingApprovals.length > 0,
6948
+ channelMeta: nextConversation.channelMeta
6871
6949
  }),
6872
6950
  this.ttl
6873
6951
  );
@@ -7384,6 +7462,7 @@ export {
7384
7462
  createDefaultTools,
7385
7463
  createDeleteDirectoryTool,
7386
7464
  createDeleteTool,
7465
+ createEditTool,
7387
7466
  createMemoryStore,
7388
7467
  createMemoryTools,
7389
7468
  createModelProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,6 +27,7 @@ export interface CronJobConfig {
27
27
  schedule: string;
28
28
  task: string;
29
29
  timezone?: string;
30
+ channel?: string;
30
31
  }
31
32
 
32
33
  export interface AgentFrontmatter {
@@ -138,10 +139,16 @@ const parseCronJobs = (
138
139
  validateTimezone(timezone, path);
139
140
  }
140
141
 
142
+ const channel =
143
+ typeof jobValue.channel === "string" && jobValue.channel.trim()
144
+ ? jobValue.channel.trim()
145
+ : undefined;
146
+
141
147
  jobs[jobName] = {
142
148
  schedule: jobValue.schedule.trim(),
143
149
  task: jobValue.task,
144
150
  timezone,
151
+ channel,
145
152
  };
146
153
  }
147
154
  return jobs;
package/src/config.ts CHANGED
@@ -39,6 +39,7 @@ export type BuiltInToolToggles = {
39
39
  list_directory?: boolean;
40
40
  read_file?: boolean;
41
41
  write_file?: boolean;
42
+ edit_file?: boolean;
42
43
  delete_file?: boolean;
43
44
  delete_directory?: boolean;
44
45
  };
@@ -89,6 +89,59 @@ export const createWriteTool = (workingDir: string): ToolDefinition =>
89
89
  },
90
90
  });
91
91
 
92
+ export const createEditTool = (workingDir: string): ToolDefinition =>
93
+ defineTool({
94
+ name: "edit_file",
95
+ description:
96
+ "Edit a file by replacing an exact string match with new content. " +
97
+ "The old_str must match exactly one location in the file (including whitespace and indentation). " +
98
+ "Use an empty new_str to delete matched content.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {
102
+ path: {
103
+ type: "string",
104
+ description: "File path relative to working directory",
105
+ },
106
+ old_str: {
107
+ type: "string",
108
+ description:
109
+ "The exact text to find and replace (must be unique in the file). " +
110
+ "Include surrounding context lines if needed to ensure uniqueness.",
111
+ },
112
+ new_str: {
113
+ type: "string",
114
+ description: "The replacement text (use empty string to delete the matched content)",
115
+ },
116
+ },
117
+ required: ["path", "old_str", "new_str"],
118
+ additionalProperties: false,
119
+ },
120
+ handler: async (input) => {
121
+ const path = typeof input.path === "string" ? input.path : "";
122
+ const oldStr = typeof input.old_str === "string" ? input.old_str : "";
123
+ const newStr = typeof input.new_str === "string" ? input.new_str : "";
124
+ if (!oldStr) throw new Error("old_str must not be empty.");
125
+ const resolved = resolveSafePath(workingDir, path);
126
+ const content = await readFile(resolved, "utf8");
127
+ const first = content.indexOf(oldStr);
128
+ if (first === -1) {
129
+ throw new Error(
130
+ "old_str not found in file. Make sure it matches exactly, including whitespace and line breaks.",
131
+ );
132
+ }
133
+ const last = content.lastIndexOf(oldStr);
134
+ if (first !== last) {
135
+ throw new Error(
136
+ "old_str appears multiple times in the file. Please provide more context to ensure a unique match.",
137
+ );
138
+ }
139
+ const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
140
+ await writeFile(resolved, newContent, "utf8");
141
+ return { path, edited: true };
142
+ },
143
+ });
144
+
92
145
  export const createDeleteTool = (workingDir: string): ToolDefinition =>
93
146
  defineTool({
94
147
  name: "delete_file",
package/src/harness.ts CHANGED
@@ -15,7 +15,7 @@ import type { UploadStore } from "./upload-store.js";
15
15
  import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
16
16
  import { parseAgentFile, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
17
17
  import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
18
- import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
18
+ import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
19
19
  import {
20
20
  createMemoryStore,
21
21
  createMemoryTools,
@@ -224,7 +224,7 @@ You are running locally in development mode. Treat this as an editable agent wor
224
224
  ## Understanding Your Environment
225
225
 
226
226
  - Built-in tools: \`list_directory\` and \`read_file\`
227
- - \`write_file\` is available in development (disabled by default in production)
227
+ - \`write_file\` and \`edit_file\` are available in development (disabled by default in production)
228
228
  - A starter local skill is included (\`starter-echo\`)
229
229
  - Bash/shell commands are **not** available unless you install and enable a shell tool/skill
230
230
  - Git operations are only available if a git-capable tool/skill is configured
@@ -280,6 +280,10 @@ cron:
280
280
  schedule: "0 9 * * *" # Standard 5-field cron expression
281
281
  timezone: "America/New_York" # Optional IANA timezone (default: UTC)
282
282
  task: "Generate the daily sales report"
283
+ telegram-checkin:
284
+ schedule: "0 18 * * 1-5"
285
+ channel: telegram # Proactive message to all known Telegram chats
286
+ task: "Send an end-of-day summary to the user"
283
287
  \`\`\`
284
288
 
285
289
  - Each cron job triggers an autonomous agent run with the specified task, creating a fresh conversation.
@@ -288,6 +292,7 @@ cron:
288
292
  - Jobs can also be triggered manually: \`GET /api/cron/<jobName>\`.
289
293
  - To carry context across cron runs, enable memory.
290
294
  - **IMPORTANT**: When adding a new cron job, always PRESERVE all existing cron jobs. Never remove or overwrite existing jobs unless the user explicitly asks you to replace or delete them. Read the full current \`cron:\` block before editing, and append the new job alongside the existing ones.
295
+ - **Proactive channel messaging**: Adding \`channel: telegram\` (or \`slack\`) makes the cron job send its response directly to all known conversations on that platform, instead of creating a standalone conversation. The agent continues the existing conversation history for context. A chat must have at least one prior user message for auto-discovery to find it.
291
296
 
292
297
  ## Messaging Integrations (Slack, Telegram, Email)
293
298
 
@@ -480,6 +485,7 @@ Since all fields have defaults, you only need to specify \`*Env\` when your env
480
485
  - If shell/CLI access is unavailable, ask the user to run needed commands and provide exact copy-paste commands.
481
486
  - For setup, skills, MCP, auth, storage, telemetry, or "how do I..." questions, proactively read \`README.md\` with \`read_file\` before answering.
482
487
  - Prefer quoting concrete commands and examples from \`README.md\` over guessing.
488
+ - Prefer \`edit_file\` for targeted changes to existing files (uses exact string matching); use \`write_file\` only for creating new files or full rewrites.
483
489
  - Keep edits minimal, preserve unrelated settings/code, and summarize what changed.
484
490
 
485
491
  ## Detailed Documentation
@@ -580,7 +586,7 @@ export class AgentHarness {
580
586
  private isToolEnabled(name: string): boolean {
581
587
  const access = this.resolveToolAccess(name);
582
588
  if (access === false) return false;
583
- if (name === "write_file" || name === "delete_file" || name === "delete_directory") {
589
+ if (name === "write_file" || name === "edit_file" || name === "delete_file" || name === "delete_directory") {
584
590
  return this.shouldEnableWriteTool();
585
591
  }
586
592
  return true;
@@ -628,6 +634,9 @@ export class AgentHarness {
628
634
  if (this.isToolEnabled("write_file")) {
629
635
  this.registerIfMissing(createWriteTool(this.workingDir));
630
636
  }
637
+ if (this.isToolEnabled("edit_file")) {
638
+ this.registerIfMissing(createEditTool(this.workingDir));
639
+ }
631
640
  if (this.isToolEnabled("delete_file")) {
632
641
  this.registerIfMissing(createDeleteTool(this.workingDir));
633
642
  }
package/src/state.ts CHANGED
@@ -50,6 +50,11 @@ export interface Conversation {
50
50
  result?: import("@poncho-ai/sdk").RunResult;
51
51
  error?: import("@poncho-ai/sdk").AgentFailure;
52
52
  };
53
+ channelMeta?: {
54
+ platform: string;
55
+ channelId: string;
56
+ platformThreadId: string;
57
+ };
53
58
  createdAt: number;
54
59
  updatedAt: number;
55
60
  }
@@ -247,6 +252,7 @@ export class InMemoryConversationStore implements ConversationStore {
247
252
  parentConversationId: c.parentConversationId,
248
253
  messageCount: c.messages.length,
249
254
  hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
255
+ channelMeta: c.channelMeta,
250
256
  }));
251
257
  }
252
258
 
@@ -305,6 +311,11 @@ export type ConversationSummary = {
305
311
  parentConversationId?: string;
306
312
  messageCount?: number;
307
313
  hasPendingApprovals?: boolean;
314
+ channelMeta?: {
315
+ platform: string;
316
+ channelId: string;
317
+ platformThreadId: string;
318
+ };
308
319
  };
309
320
 
310
321
  type ConversationStoreFile = {
@@ -319,6 +330,11 @@ type ConversationStoreFile = {
319
330
  parentConversationId?: string;
320
331
  messageCount?: number;
321
332
  hasPendingApprovals?: boolean;
333
+ channelMeta?: {
334
+ platform: string;
335
+ channelId: string;
336
+ platformThreadId: string;
337
+ };
322
338
  }>;
323
339
  };
324
340
 
@@ -451,6 +467,7 @@ class FileConversationStore implements ConversationStore {
451
467
  parentConversationId: conversation.parentConversationId,
452
468
  messageCount: conversation.messages.length,
453
469
  hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0,
470
+ channelMeta: conversation.channelMeta,
454
471
  });
455
472
  await this.writeIndex();
456
473
  });
@@ -486,6 +503,7 @@ class FileConversationStore implements ConversationStore {
486
503
  parentConversationId: c.parentConversationId,
487
504
  messageCount: c.messageCount,
488
505
  hasPendingApprovals: c.hasPendingApprovals,
506
+ channelMeta: c.channelMeta,
489
507
  }));
490
508
  }
491
509
 
@@ -660,6 +678,11 @@ type ConversationMeta = {
660
678
  parentConversationId?: string;
661
679
  messageCount?: number;
662
680
  hasPendingApprovals?: boolean;
681
+ channelMeta?: {
682
+ platform: string;
683
+ channelId: string;
684
+ platformThreadId: string;
685
+ };
663
686
  };
664
687
 
665
688
  abstract class KeyValueConversationStoreBase implements ConversationStore {
@@ -804,6 +827,7 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
804
827
  parentConversationId: meta.parentConversationId,
805
828
  messageCount: meta.messageCount,
806
829
  hasPendingApprovals: meta.hasPendingApprovals,
830
+ channelMeta: meta.channelMeta,
807
831
  });
808
832
  }
809
833
  } catch { /* skip invalid records */ }
@@ -867,6 +891,7 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
867
891
  parentConversationId: nextConversation.parentConversationId,
868
892
  messageCount: nextConversation.messages.length,
869
893
  hasPendingApprovals: Array.isArray(nextConversation.pendingApprovals) && nextConversation.pendingApprovals.length > 0,
894
+ channelMeta: nextConversation.channelMeta,
870
895
  } satisfies ConversationMeta),
871
896
  this.ttl,
872
897
  );
@@ -1,10 +1,11 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { createServer } from "node:http";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "path";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import type { ToolContext } from "@poncho-ai/sdk";
7
7
  import { AgentHarness } from "../src/harness.js";
8
+ import { createEditTool } from "../src/default-tools.js";
8
9
  import { loadSkillMetadata } from "../src/skill-context.js";
9
10
 
10
11
  const stubContext: ToolContext = {
@@ -39,6 +40,7 @@ model:
39
40
  expect(names).toContain("list_directory");
40
41
  expect(names).toContain("read_file");
41
42
  expect(names).toContain("write_file");
43
+ expect(names).toContain("edit_file");
42
44
  });
43
45
 
44
46
  it("disables write_file by default in production environment", async () => {
@@ -64,6 +66,7 @@ model:
64
66
  expect(names).toContain("list_directory");
65
67
  expect(names).toContain("read_file");
66
68
  expect(names).not.toContain("write_file");
69
+ expect(names).not.toContain("edit_file");
67
70
  });
68
71
 
69
72
  it("allows disabling built-in tools via poncho.config.js", async () => {
@@ -1230,3 +1233,63 @@ allowed-tools:
1230
1233
  });
1231
1234
 
1232
1235
  });
1236
+
1237
+ describe("edit_file tool", () => {
1238
+ it("replaces a unique string match in a file", async () => {
1239
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-"));
1240
+ const filePath = join(dir, "test.txt");
1241
+ await writeFile(filePath, "hello world\nfoo bar\nbaz qux\n", "utf8");
1242
+
1243
+ const tool = createEditTool(dir);
1244
+ const result = await tool.handler(
1245
+ { path: "test.txt", old_str: "foo bar", new_str: "replaced" },
1246
+ stubContext,
1247
+ );
1248
+
1249
+ expect(result).toEqual({ path: "test.txt", edited: true });
1250
+ const content = await readFile(filePath, "utf8");
1251
+ expect(content).toBe("hello world\nreplaced\nbaz qux\n");
1252
+ });
1253
+
1254
+ it("errors when old_str is not found in the file", async () => {
1255
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-notfound-"));
1256
+ await writeFile(join(dir, "test.txt"), "hello world\n", "utf8");
1257
+
1258
+ const tool = createEditTool(dir);
1259
+ await expect(
1260
+ tool.handler({ path: "test.txt", old_str: "nonexistent", new_str: "x" }, stubContext),
1261
+ ).rejects.toThrow("old_str not found in file");
1262
+ });
1263
+
1264
+ it("errors when old_str matches multiple locations", async () => {
1265
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-multi-"));
1266
+ await writeFile(join(dir, "test.txt"), "aaa\nbbb\naaa\n", "utf8");
1267
+
1268
+ const tool = createEditTool(dir);
1269
+ await expect(
1270
+ tool.handler({ path: "test.txt", old_str: "aaa", new_str: "ccc" }, stubContext),
1271
+ ).rejects.toThrow("old_str appears multiple times");
1272
+ });
1273
+
1274
+ it("deletes matched content when new_str is empty", async () => {
1275
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-delete-"));
1276
+ const filePath = join(dir, "test.txt");
1277
+ await writeFile(filePath, "keep this\nremove this\nkeep this too\n", "utf8");
1278
+
1279
+ const tool = createEditTool(dir);
1280
+ await tool.handler({ path: "test.txt", old_str: "remove this\n", new_str: "" }, stubContext);
1281
+
1282
+ const content = await readFile(filePath, "utf8");
1283
+ expect(content).toBe("keep this\nkeep this too\n");
1284
+ });
1285
+
1286
+ it("errors when old_str is empty", async () => {
1287
+ const dir = await mkdtemp(join(tmpdir(), "poncho-edit-tool-empty-"));
1288
+ await writeFile(join(dir, "test.txt"), "content\n", "utf8");
1289
+
1290
+ const tool = createEditTool(dir);
1291
+ await expect(
1292
+ tool.handler({ path: "test.txt", old_str: "", new_str: "x" }, stubContext),
1293
+ ).rejects.toThrow("old_str must not be empty");
1294
+ });
1295
+ });