openclacky 0.7.5 → 0.7.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f623e852b5705d9339509514773528e2937fe90fe1fb8feca214a44633aa4d8d
4
- data.tar.gz: bfff8a16fc705012a2d30a4ea8ac9a90ac6506ddbd45d9f30debe6019d04c52c
3
+ metadata.gz: 001070cf8c2d2587620da0669c4cc014bff55930033beafd038ae86ae7374276
4
+ data.tar.gz: 6c08cf048ab754c8e3be4841496b19b14cf081bdc62cadb313070226cb4e8679
5
5
  SHA512:
6
- metadata.gz: 06cf4fe9c03e899ad4f9ccf6a325c10c1c952179240e37d5f18bd9c40b3d0088a4928c2536c578ea4735a606e55d3b5d58ffe9c19d21830f5589e002c99b887a
7
- data.tar.gz: aa8bf7a73ca171fc1a234bfd3fac2ed51db67c18610fa525179d656186853d00023b0835d8bc7590c772f57563957299968f540239c1d736057d43dcd9f4488c
6
+ metadata.gz: 1bd44e7efcb16f9d9a15bb49c8d253183697107dcbb25f9ad8edc624d1a357d67c7f54f974cc06ae2140f66c0dec4bd2c2d8b50fae93ad3a8cf2453a0ee5f808
7
+ data.tar.gz: 4328e4ff99db8731e5ed0a1c298c281261da097d214b600e8d6d1b544073055cc35145d76f53853ea30f6f4827f831cd3444129559ff66e10b4d5a23b30e71e0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.6] - 2026-03-02
11
+
12
+ ### Added
13
+ - Non-interactive `--message`/`-m` CLI mode for scripting and automation (run a single prompt and exit)
14
+ - Real-time refresh and thread-safety improvements to fullscreen UI mode
15
+
16
+ ### Improved
17
+ - Extract string matching logic into `Utils::StringMatcher` for cleaner, reusable edit diffing
18
+ - Glob tool now uses force mode in system prompt for more reliable file discovery
19
+ - VCS directories (`.git`, `.svn`, etc.) defined as `ALWAYS_IGNORED_DIRS` constant
20
+
21
+ ### Fixed
22
+ - Subagent fork now injects assistant acknowledgment to fix conversation structure issues
23
+ - Tool-denial message clarified; added `action_performed` flag for better control flow
24
+
25
+ ### More
26
+ - Add memory architecture documentation
27
+ - Minor whitespace cleanup in `agent_config.rb`
28
+
10
29
  ## [0.7.5] - 2026-02-28
11
30
 
12
31
  ### Fixed
@@ -0,0 +1,343 @@
1
+ # Agent 记忆架构:主动写入 vs 记忆压缩
2
+
3
+ > **目标读者**:想在自己的 Agent 项目中复用 OpenClaw 记忆架构的开发者。
4
+ >
5
+ > **适用场景**:基于 LLM 的长期运行 Agent,需要跨 session 保留知识、同时管理上下文窗口溢出。
6
+
7
+ ---
8
+
9
+ ## 一、两套机制概览
10
+
11
+ OpenClaw 的"记忆"由两个**完全独立、互相补充**的机制构成:
12
+
13
+ | 机制 | 核心目标 | 存储位置 | 跨 Session |
14
+ |---|---|---|---|
15
+ | **主动写入 MEMORY.md** | 知识持久化 | 磁盘 `.md` 文件 | ✅ 永久保留 |
16
+ | **Compaction(记忆压缩)** | 上下文窗口管理 | 内存消息历史(in-memory) | ❌ session 结束即消失 |
17
+
18
+ ---
19
+
20
+ ## 二、主动写入 MEMORY.md
21
+
22
+ ### 2.1 提示词来源
23
+
24
+ **写入指令**不在代码里硬编码,而是来自 workspace 的 `AGENTS.md` 文件。
25
+ Session 启动时,系统通过以下链路将 `AGENTS.md` 注入系统提示词:
26
+
27
+ ```
28
+ resolveBootstrapContextForRun()
29
+ └─ buildBootstrapContextFiles()
30
+ └─ contextFiles[]
31
+ └─ 注入到系统提示词的 "# Project Context" 段落
32
+ ```
33
+
34
+ **只有读取指令**被硬编码在 `system-prompt.ts` 的 `buildMemorySection()` 里:
35
+
36
+ ```
37
+ ## Memory Recall
38
+ Before answering anything about prior work, decisions, dates, people, preferences,
39
+ or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull
40
+ only the needed lines.
41
+ ```
42
+
43
+ > 结论:**读的提示词 → 代码硬编码**,**写的提示词 → AGENTS.md(用户可自定义)**。
44
+
45
+ ---
46
+
47
+ ### 2.2 AGENTS.md 写入指令原文
48
+
49
+ 以下是 `docs/reference/templates/AGENTS.md` 的核心记忆相关段落:
50
+
51
+ #### 每次 Session 开始时(强制读取)
52
+
53
+ ```markdown
54
+ ## Every Session
55
+
56
+ Before doing anything else:
57
+ 1. Read `SOUL.md` — this is who you are
58
+ 2. Read `USER.md` — this is who you're helping
59
+ 3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
60
+ 4. **If in MAIN SESSION**: Also read `MEMORY.md`
61
+
62
+ Don't ask permission. Just do it.
63
+ ```
64
+
65
+ #### MEMORY.md 长期记忆规则
66
+
67
+ ```markdown
68
+ ### 🧠 MEMORY.md - Your Long-Term Memory
69
+
70
+ - **ONLY load in main session** (direct chats with your human)
71
+ - **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
72
+ - This is for **security** — contains personal context that shouldn't leak to strangers
73
+ - You can **read, edit, and update** MEMORY.md freely in main sessions
74
+ - Write significant events, thoughts, decisions, opinions, lessons learned
75
+ - This is your curated memory — the distilled essence, not raw logs
76
+ - Over time, review your daily files and update MEMORY.md with what's worth keeping
77
+ ```
78
+
79
+ #### 写入原则:禁止"心理便条"
80
+
81
+ ```markdown
82
+ ### 📝 Write It Down - No "Mental Notes"!
83
+
84
+ - **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
85
+ - "Mental notes" don't survive session restarts. Files do.
86
+ - When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
87
+ - When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
88
+ ```
89
+
90
+ #### Heartbeat 期间的维护任务
91
+
92
+ ```markdown
93
+ ### 🔄 Memory Maintenance (During Heartbeats)
94
+
95
+ 1. Read through recent `memory/YYYY-MM-DD.md` files
96
+ 2. Update `MEMORY.md` with distilled learnings
97
+ 3. Remove outdated info from MEMORY.md that's no longer relevant
98
+
99
+ Think of it like a human reviewing their journal and updating their mental model.
100
+ Daily files are raw notes; MEMORY.md is curated wisdom.
101
+ ```
102
+
103
+ ---
104
+
105
+ ### 2.3 记忆文件结构
106
+
107
+ ```
108
+ workspace/
109
+ ├── AGENTS.md # Agent 行为指令(含写入规则),session 启动时注入系统提示词
110
+ ├── SOUL.md # Agent 的性格/价值观
111
+ ├── USER.md # 用户背景信息
112
+ ├── MEMORY.md # 长期记忆(精华,仅主 session 加载)
113
+ └── memory/
114
+ ├── 2026-06-15.md # 今日日志(原始记录)
115
+ ├── 2026-06-14.md # 昨日日志
116
+ └── ...
117
+ ```
118
+
119
+ **两层记忆设计**:
120
+ - `memory/YYYY-MM-DD.md`:**原始日志**,当天发生了什么,快速写入,不筛选
121
+ - `MEMORY.md`:**精华提炼**,LLM 主动筛选后写入,类似人类的长期记忆
122
+
123
+ ---
124
+
125
+ ## 三、Compaction(记忆压缩)
126
+
127
+ ### 3.1 触发时机
128
+
129
+ 当 context window 接近上限(token 溢出)时,系统自动触发:
130
+
131
+ ```
132
+ attempt.ts(run loop)
133
+ └─ 检测 token 超限(overflow)
134
+ └─ compactInLane()
135
+ └─ session.compact(customInstructions)
136
+ └─ generateSummary()
137
+ └─ 旧消息被摘要文本替换(in-memory)
138
+ ```
139
+
140
+ ### 3.2 核心实现
141
+
142
+ **`src/agents/compaction.ts`** 中的 `summarizeChunks()`:
143
+
144
+ ```typescript
145
+ // 将消息历史分块,逐块生成摘要
146
+ async function summarizeChunks(params: {
147
+ messages: AgentMessage[];
148
+ model: ...;
149
+ previousSummary?: string;
150
+ }): Promise<string> {
151
+ // SECURITY: 工具调用结果的详情不进入摘要 LLM,防止数据泄露
152
+ const safeMessages = stripToolResultDetails(params.messages);
153
+ const chunks = chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens);
154
+
155
+ let summary = params.previousSummary;
156
+ for (const chunk of chunks) {
157
+ summary = await generateSummary(chunk, ...);
158
+ }
159
+ return summary ?? "No prior history.";
160
+ }
161
+ ```
162
+
163
+ **`src/agents/pi-embedded-runner/compact.ts`** 中的触发逻辑:
164
+
165
+ ```typescript
166
+ const result = await compactWithSafetyTimeout(() =>
167
+ session.compact(params.customInstructions),
168
+ );
169
+ // compaction 完成后,session.messages 中的旧消息已被摘要替换
170
+ // 注意:不写磁盘,session 结束即消失
171
+ ```
172
+
173
+ ### 3.3 安全设计
174
+
175
+ - `stripToolResultDetails()`:确保工具调用的详细返回值不进入摘要 LLM(防止敏感数据泄露给压缩模型)
176
+ - Compaction 生成的摘要只替换内存里的消息,**不写任何磁盘文件**
177
+
178
+ ---
179
+
180
+ ## 四、两套机制完整对比
181
+
182
+ | 维度 | **主动写入 MEMORY.md** | **Compaction(记忆压缩)** |
183
+ |---|---|---|
184
+ | **操作对象** | 磁盘文件(`.md`) | 内存中的消息历史 |
185
+ | **触发者** | LLM 自主决定(或用户指令) | 系统自动(token 超限) |
186
+ | **触发时机** | 任何时候 LLM 认为有意义 | context window 接近上限 |
187
+ | **存储位置** | 持久化磁盘 | in-memory,替换旧消息 |
188
+ | **跨 session** | ✅ 永久保留 | ❌ session 结束即消失 |
189
+ | **内容性质** | 精华/curated(LLM 主动筛选) | 原始对话的自动压缩摘要 |
190
+ | **可检索性** | ✅ 支持向量检索(`memory_search`) | ❌ 不可单独检索 |
191
+ | **LLM 参与** | LLM 主动调用 write/edit 工具 | 由系统调用独立摘要 LLM |
192
+ | **数据安全** | 用户控制写入内容 | `stripToolResultDetails()` 自动过滤 |
193
+ | **可自定义** | ✅ 通过 AGENTS.md 自定义规则 | ✅ 可传入 `customInstructions` |
194
+
195
+ ---
196
+
197
+ ## 五、架构图
198
+
199
+ ```
200
+ ┌──────────────────────────────────────────────────────────────────┐
201
+ │ 当前 Session 上下文 │
202
+ │ │
203
+ │ [系统提示词] │
204
+ │ ├─ AGENTS.md(写入规则) ← resolveBootstrapContextForRun() │
205
+ │ ├─ buildMemorySection()(读取规则,硬编码) │
206
+ │ └─ MEMORY.md 内容(主 session 才注入) │
207
+ │ │
208
+ │ [消息历史] [消息1][消息2]...[消息N] ← in-memory │
209
+ │ ─────────────────────────────────── context window limit │
210
+ │ │
211
+ │ ⚡ 快满了 → Compaction 触发: │
212
+ │ 旧消息 ──→ summarizeChunks() ──→ [摘要文本] │
213
+ │ [摘要] 替换旧消息(仍在 in-memory) │
214
+ │ ↑ 只压缩窗口,不写磁盘,session 结束消失 │
215
+ └──────────────────────────────────────────────────────────────────┘
216
+ ↕ 互相独立,互相补充
217
+ ┌──────────────────────────────────────────────────────────────────┐
218
+ │ 磁盘持久化记忆系统 │
219
+ │ │
220
+ │ LLM 主动 write/edit 工具 ───────────────────────────────────── │
221
+ │ ↓ 用户说"记住这个" │
222
+ │ ↓ LLM 觉得值得保留 │
223
+ │ ↓ Heartbeat 定期维护 │
224
+ │ │
225
+ │ MEMORY.md ← 精华长期记忆(仅主 session 读取) │
226
+ │ memory/2026-06-15.md ← 今日原始日志 │
227
+ │ memory/2026-06-14.md ← 昨日日志 │
228
+ │ │
229
+ │ chokidar watch ──→ SQLite 向量索引更新 │
230
+ │ memory_search ──→ 下次 session 可检索 │
231
+ └──────────────────────────────────────────────────────────────────┘
232
+ ```
233
+
234
+ ---
235
+
236
+ ## 六、关键文件清单
237
+
238
+ | 文件 | 作用 |
239
+ |---|---|
240
+ | `docs/reference/templates/AGENTS.md` | **写入指令来源**,workspace 启动模板,包含 MEMORY.md 写入规则 |
241
+ | `src/agents/system-prompt.ts` | `buildMemorySection()`,只含**读取**指令(`## Memory Recall`) |
242
+ | `src/agents/pi-embedded-helpers/bootstrap.ts` | `buildBootstrapContextFiles()`,将 AGENTS.md 等文件注入系统提示词 |
243
+ | `src/agents/bootstrap-files.ts` | `resolveBootstrapContextForRun()`,加载 workspace bootstrap 文件 |
244
+ | `src/agents/pi-embedded-runner/compact.ts` | Compaction 入口,`session.compact()` 触发,管理上下文溢出 |
245
+ | `src/agents/compaction.ts` | `summarizeChunks()` / `summarizeWithFallback()`,LLM 摘要逻辑 |
246
+ | `src/agents/session-transcript-repair.ts` | `stripToolResultDetails()`,Compaction 安全过滤 |
247
+
248
+ ---
249
+
250
+ ## 七、在新项目中复用这套架构
251
+
252
+ ### 7.1 最小实现方案
253
+
254
+ 只需要三样东西:
255
+
256
+ ```
257
+ 1. AGENTS.md(写入规则) → 告诉 LLM 什么时候、怎么写文件
258
+ 2. 文件读写工具 → write / read / edit(标准文件操作工具)
259
+ 3. 系统提示词中的读取指令 → 告诉 LLM 在回答前先查记忆
260
+ ```
261
+
262
+ ### 7.2 推荐的 AGENTS.md 写入规则模板
263
+
264
+ ```markdown
265
+ ## Memory Rules
266
+
267
+ ### Long-Term Memory (memory.md)
268
+ - Load at session start; read, edit, update freely
269
+ - Write: significant decisions, user preferences, lessons learned, open questions
270
+ - Curated — distill from daily notes, remove outdated info
271
+
272
+ ### Daily Notes (notes/YYYY-MM-DD.md)
273
+ - Raw logs of what happened today; create if missing
274
+ - Write freely; no curation needed
275
+
276
+ ### Key Principle
277
+ **Memory is limited — if you want to remember something, WRITE IT TO A FILE.**
278
+ Mental notes don't survive restarts. Files do.
279
+
280
+ When user says "remember this" → update notes/YYYY-MM-DD.md and/or memory.md.
281
+ When you learn a lesson → update memory.md.
282
+ ```
283
+
284
+ ### 7.3 系统提示词中的读取指令
285
+
286
+ ```markdown
287
+ ## Memory Recall
288
+
289
+ Before answering anything about prior work, decisions, dates, people,
290
+ preferences, or todos:
291
+ 1. Read memory.md for long-term context
292
+ 2. Read notes/YYYY-MM-DD.md (today + yesterday) for recent context
293
+ 3. Then answer using this retrieved context
294
+ ```
295
+
296
+ ### 7.4 Compaction 实现要点
297
+
298
+ 如果你的 Agent 框架不内置 compaction,参考以下要点自己实现:
299
+
300
+ ```typescript
301
+ // 伪代码:简单 compaction 实现
302
+ async function compactIfNeeded(messages: Message[], model: Model) {
303
+ const tokens = estimateTokens(messages);
304
+ if (tokens < contextWindow * 0.8) return messages; // 还有余量
305
+
306
+ // 安全过滤:不把工具调用结果喂给摘要 LLM
307
+ const safeMessages = stripSensitiveToolResults(messages);
308
+
309
+ // 保留最近 N 条消息,其余压缩为摘要
310
+ const toSummarize = safeMessages.slice(0, -10);
311
+ const recent = messages.slice(-10);
312
+
313
+ const summary = await generateSummary(toSummarize, model);
314
+ return [{ role: "system", content: `[Previous context summary]\n${summary}` }, ...recent];
315
+ }
316
+ ```
317
+
318
+ **关键安全注意**:工具调用返回的详细数据(尤其是外部 API 响应)**不应进入**摘要 LLM,避免敏感信息泄露到你可能无法控制的模型。
319
+
320
+ ### 7.5 两层记忆的设计哲学
321
+
322
+ | 层级 | 文件 | 写入频率 | 内容要求 | 对应人类记忆 |
323
+ |---|---|---|---|---|
324
+ | 日志层 | `notes/YYYY-MM-DD.md` | 高频、随时 | 原始记录,不筛选 | 日记 |
325
+ | 精华层 | `memory.md` | 低频、定期整理 | 蒸馏后的关键信息 | 长期记忆 |
326
+
327
+ **为什么要两层**:高频写入保证不遗漏,低频整理保证质量。LLM 在 heartbeat(定期任务)中把日志蒸馏进长期记忆,删除过时内容,就像人类每周回顾日记、更新心智模型。
328
+
329
+ ---
330
+
331
+ ## 八、常见问题
332
+
333
+ **Q:为什么写入规则放 AGENTS.md 而不是硬编码在系统提示词里?**
334
+ A:灵活性。不同 workspace 可以有不同的记忆规则(有的 Agent 记更多,有的记更少),用户可以直接编辑 AGENTS.md 调整行为,不需要改代码。
335
+
336
+ **Q:Compaction 会不会把写入 MEMORY.md 的内容也压缩掉?**
337
+ A:不会。写入 MEMORY.md 是磁盘操作,Compaction 只压缩内存里的消息历史。MEMORY.md 的内容在下次 session 启动时会重新从磁盘加载进系统提示词,不受 Compaction 影响。
338
+
339
+ **Q:如果 MEMORY.md 本身太大怎么办?**
340
+ A:定期在 heartbeat 任务中让 LLM 清理过时内容。OpenClaw 在 AGENTS.md 里明确要求"Remove outdated info from MEMORY.md that's no longer relevant",这个维护任务由 LLM 自主完成。
341
+
342
+ **Q:多用户场景下 MEMORY.md 会泄露给别人吗?**
343
+ A:OpenClaw 的设计是:MEMORY.md 只在 main session(直接对话)加载,在 Discord / 群聊等共享上下文中不加载,防止个人信息泄露给陌生人。复用时需要在系统提示词里加类似的条件判断。
@@ -100,17 +100,21 @@ module Clacky
100
100
  # Log subagent fork
101
101
  @ui&.show_info("Subagent start: #{skill.identifier}")
102
102
 
103
- # Build task content from skill
104
- task_content = skill.process_content(arguments)
103
+ # Build skill role/constraint instructions only — do NOT substitute $ARGUMENTS here.
104
+ # The actual task is delivered as a clean user message via subagent.run(arguments),
105
+ # which arrives *after* the assistant acknowledgement injected by fork_subagent.
106
+ # This gives the subagent a clear 3-part structure:
107
+ # [user] role/constraints → [assistant] acknowledgement → [user] actual task
108
+ skill_instructions = skill.process_content("")
105
109
 
106
110
  # Fork subagent with skill configuration
107
111
  subagent = fork_subagent(
108
112
  model: skill.subagent_model,
109
113
  forbidden_tools: skill.forbidden_tools_list,
110
- system_prompt_suffix: task_content
114
+ system_prompt_suffix: skill_instructions
111
115
  )
112
116
 
113
- # Run subagent
117
+ # Run subagent with the actual task as the sole user turn
114
118
  result = subagent.run(arguments)
115
119
 
116
120
  # Generate summary
@@ -24,7 +24,7 @@ module Clacky
24
24
  - After creating the TODO list, START EXECUTING each task immediately
25
25
  - Don't stop after planning - continue to work on the tasks!
26
26
  2. Always read existing code before making changes (use file_reader/glob/grep or invoke code-explorer skill)
27
- 3. **Use glob tool to search for files** - it respects .gitignore, filters binary files, and sorts by modification time
27
+ 3. **ALWAYS use `glob` tool to find files in the current directory NEVER use shell `find` command for file discovery.**
28
28
  4. Ask clarifying questions if requirements are unclear
29
29
  5. Break down complex tasks into manageable steps
30
30
  6. **USE TOOLS to create/modify files** - don't just return code
@@ -197,14 +197,16 @@ module Clacky
197
197
  }
198
198
  else
199
199
  # User manually denied or provided feedback
200
+ # Clearly state the action was NOT performed so the LLM knows the change did not happen
200
201
  message = if user_feedback && !user_feedback.empty?
201
- "Tool use denied by user. User feedback: #{user_feedback}"
202
+ "Tool use denied by user. This action was NOT performed. User feedback: #{user_feedback}"
202
203
  else
203
- "Tool use denied by user"
204
+ "Tool use denied by user. This action was NOT performed."
204
205
  end
205
206
 
206
207
  tool_content = {
207
208
  error: message,
209
+ action_performed: false,
208
210
  user_feedback: user_feedback
209
211
  }
210
212
  end
@@ -382,8 +384,12 @@ module Clacky
382
384
 
383
385
  file_content = File.read(path)
384
386
 
385
- # Check if old_string exists in file
386
- unless file_content.include?(old_string)
387
+ # Use the same find_match logic as Edit tool to handle fuzzy matching
388
+ # (trim, unescape, smart line matching) — prevents diff from being blank
389
+ # when simple include? fails but Edit#execute's fuzzy match would succeed
390
+ match_result = Utils::StringMatcher.find_match(file_content, old_string)
391
+
392
+ unless match_result
387
393
  # Log debug info for troubleshooting
388
394
  @debug_logs << {
389
395
  timestamp: Time.now.iso8601,
@@ -402,11 +408,14 @@ module Clacky
402
408
  }
403
409
  end
404
410
 
411
+ # Use the actual matched string (may differ via trim/unescape) for replacement
412
+ actual_old_string = match_result[:matched_string]
413
+
405
414
  # Use the same replace logic as the actual tool execution
406
415
  new_content = if replace_all
407
- file_content.gsub(old_string, new_string)
416
+ file_content.gsub(actual_old_string, new_string)
408
417
  else
409
- file_content.sub(old_string, new_string)
418
+ file_content.sub(actual_old_string, new_string)
410
419
  end
411
420
  @ui&.show_diff(file_content, new_content, max_lines: 50)
412
421
  nil # No error
data/lib/clacky/agent.rb CHANGED
@@ -506,7 +506,16 @@ module Clacky
506
506
  if result.is_a?(Hash) && result[:message]
507
507
  @ui&.show_assistant_message(result[:message])
508
508
  end
509
- awaiting_feedback = true
509
+
510
+ if @config.permission_mode == :auto_approve
511
+ # Auto-approve mode means no human is watching — inject a reply so the LLM
512
+ # knows it should make a reasonable decision and keep going
513
+ result = result.merge(
514
+ auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
515
+ )
516
+ else
517
+ awaiting_feedback = true
518
+ end
510
519
  else
511
520
  # Use tool's format_result method to get display-friendly string
512
521
  formatted_result = tool.respond_to?(:format_result) ? tool.format_result(result) : result.to_s
@@ -691,6 +700,15 @@ module Clacky
691
700
  system_injected: true,
692
701
  subagent_instructions: true
693
702
  }
703
+
704
+ # Insert an assistant acknowledgement so the conversation structure is complete:
705
+ # [user] role/constraints → [assistant] ack → [user] actual task (from run())
706
+ # Without this, two consecutive user messages confuse the model about what to act on.
707
+ messages << {
708
+ role: "assistant",
709
+ content: "Understood. I am now operating as a subagent with the constraints above. Please provide the task.",
710
+ system_injected: true
711
+ }
694
712
  end
695
713
 
696
714
  # Register hook to forbid certain tools at runtime (doesn't affect tool registry for cache)
@@ -150,7 +150,7 @@ module Clacky
150
150
 
151
151
  PERMISSION_MODES = [:auto_approve, :confirm_safes, :plan_only].freeze
152
152
 
153
- attr_accessor :permission_mode, :max_tokens, :verbose,
153
+ attr_accessor :permission_mode, :max_tokens, :verbose,
154
154
  :enable_compression, :enable_prompt_caching,
155
155
  :models, :current_model_index
156
156
 
@@ -161,7 +161,7 @@ module Clacky
161
161
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
162
162
  # Enable prompt caching by default for cost savings
163
163
  @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
164
-
164
+
165
165
  # Models configuration
166
166
  @models = options[:models] || []
167
167
  @current_model_index = options[:current_model_index] || 0
data/lib/clacky/cli.rb CHANGED
@@ -5,6 +5,7 @@ require "tty-prompt"
5
5
  require "fileutils"
6
6
  require_relative "ui2"
7
7
  require_relative "json_ui_controller"
8
+ require_relative "plain_ui_controller"
8
9
 
9
10
  module Clacky
10
11
  class CLI < Thor
@@ -50,6 +51,8 @@ module Clacky
50
51
  option :list, type: :boolean, aliases: "-l", desc: "List recent sessions"
51
52
  option :attach, type: :string, aliases: "-a", desc: "Attach to session by number or keyword"
52
53
  option :json, type: :boolean, default: false, desc: "Output NDJSON to stdout (for scripting/piping)"
54
+ option :message, type: :string, aliases: "-m", desc: "Run non-interactively with this message and exit"
55
+ option :image, type: :array, aliases: "-i", desc: "Image file path(s) to attach (use with -m; can be specified multiple times)"
53
56
  option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
54
57
  def agent
55
58
  # Handle help option
@@ -101,7 +104,9 @@ module Clacky
101
104
  should_chdir = File.realpath(working_dir) != File.realpath(original_dir)
102
105
  Dir.chdir(working_dir) if should_chdir
103
106
  begin
104
- if options[:json]
107
+ if options[:message]
108
+ run_non_interactive(agent, options[:message], Array(options[:image]), agent_config, session_manager)
109
+ elsif options[:json]
105
110
  run_agent_with_json(agent, working_dir, agent_config, session_manager, client)
106
111
  else
107
112
  run_agent_with_ui2(agent, working_dir, agent_config, session_manager, client, is_session_load: is_session_load)
@@ -353,6 +358,33 @@ module Clacky
353
358
  end
354
359
  end
355
360
 
361
+ # Run agent non-interactively with a single message, then exit.
362
+ # Forces auto_approve mode so no human confirmation is needed.
363
+ # Output goes directly to stdout; exits with code 0 on success, 1 on error.
364
+ def run_non_interactive(agent, message, images, agent_config, session_manager)
365
+ # Force auto-approve — no one is around to confirm anything
366
+ agent_config.permission_mode = :auto_approve
367
+
368
+ # Validate image paths up-front so we fail fast with a clear message
369
+ images.each do |path|
370
+ raise ArgumentError, "Image file not found: #{path}" unless File.exist?(path)
371
+ end
372
+
373
+ # Wire up plain-text stdout UI so all agent output is visible
374
+ plain_ui = Clacky::PlainUIController.new
375
+ agent.instance_variable_set(:@ui, plain_ui)
376
+
377
+ agent.run(message, images: images)
378
+ session_manager&.save(agent.to_session_data(status: :success))
379
+ exit(0)
380
+ rescue Clacky::AgentInterrupted
381
+ $stderr.puts "\nInterrupted."
382
+ exit(1)
383
+ rescue => e
384
+ $stderr.puts "Error: #{e.message}"
385
+ exit(1)
386
+ end
387
+
356
388
  # Run agent with JSON (NDJSON) output mode — persistent process.
357
389
  # Reads JSON messages from stdin, writes NDJSON events to stdout.
358
390
  # Stays alive until "/exit", {"type":"exit"}, or stdin EOF.