openclacky 1.0.2 → 1.0.4

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  4. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  5. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  6. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  7. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  8. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  9. data/benchmark/results/baseline_20260511_174424.json +128 -0
  10. data/benchmark/results/report_20260511_175256.json +271 -0
  11. data/benchmark/results/report_20260511_175444.json +271 -0
  12. data/benchmark/results/treatment_20260511_175103.json +130 -0
  13. data/benchmark/runner.rb +441 -0
  14. data/docs/proposals/2026-05-11-system-prompt-alignment.md +325 -0
  15. data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +89 -0
  16. data/lib/clacky/agent/cost_tracker.rb +8 -2
  17. data/lib/clacky/agent/llm_caller.rb +218 -0
  18. data/lib/clacky/agent/memory_updater.rb +41 -30
  19. data/lib/clacky/agent/message_compressor.rb +15 -4
  20. data/lib/clacky/agent/message_compressor_helper.rb +41 -2
  21. data/lib/clacky/agent/skill_manager.rb +5 -2
  22. data/lib/clacky/agent/skill_reflector.rb +10 -1
  23. data/lib/clacky/agent/tool_registry.rb +109 -0
  24. data/lib/clacky/agent.rb +20 -0
  25. data/lib/clacky/agent_config.rb +17 -0
  26. data/lib/clacky/cli.rb +65 -0
  27. data/lib/clacky/client.rb +15 -0
  28. data/lib/clacky/default_agents/base_prompt.md +20 -20
  29. data/lib/clacky/default_agents/coding/system_prompt.md +51 -1
  30. data/lib/clacky/default_skills/channel-setup/SKILL.md +113 -5
  31. data/lib/clacky/default_skills/channel-setup/import_lark_skills.rb +97 -0
  32. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  33. data/lib/clacky/default_skills/persist-memory/SKILL.md +59 -0
  34. data/lib/clacky/providers.rb +48 -6
  35. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
  36. data/lib/clacky/server/channel/channel_manager.rb +91 -0
  37. data/lib/clacky/server/discover.rb +77 -0
  38. data/lib/clacky/server/epipe_safe_io.rb +105 -0
  39. data/lib/clacky/server/http_server.rb +121 -41
  40. data/lib/clacky/server/server_master.rb +6 -0
  41. data/lib/clacky/skill.rb +30 -0
  42. data/lib/clacky/utils/file_processor.rb +71 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +58 -22
  45. data/lib/clacky/web/i18n.js +4 -2
  46. data/lib/clacky/web/sessions.js +29 -17
  47. metadata +33 -2
@@ -3,7 +3,7 @@ users complete software development projects. You are responsible for developmen
3
3
 
4
4
  Your role is to:
5
5
  - Understand project requirements and translate them into technical solutions
6
- - Write clean, maintainable, and well-documented code
6
+ - Write clean, maintainable code
7
7
  - Follow best practices and industry standards
8
8
  - Explain technical concepts in simple terms when needed
9
9
  - Proactively identify potential issues and suggest improvements
@@ -15,3 +15,53 @@ Working process:
15
15
  3. You should frequently refer to the existing codebase. For unclear instructions,
16
16
  prioritize understanding the codebase first before answering or taking action.
17
17
  Always read relevant code files to understand the project structure, patterns, and conventions.
18
+
19
+ ## Code Style
20
+
21
+ - **Default to writing no comments.** Only add one when the WHY is non-obvious: a hidden constraint, a subtle invariant, a workaround for a specific bug, or behavior that would surprise a reader.
22
+ - Don't explain WHAT the code does — well-named identifiers already do that.
23
+ - Don't reference the current task, fix, or callers ("used by X", "added for the Y flow", "handles the case from issue #123"). These belong in the PR description and rot as the codebase evolves.
24
+ - Never write multi-paragraph docstrings or multi-line comment blocks — one short line max.
25
+
26
+ ## File Modification Rules
27
+
28
+ - **ALWAYS prefer `edit` over `write`.** Use `write` only for creating entirely new files or complete rewrites.
29
+ - When editing text from `file_reader` output, preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
30
+ - Ensure `old_string` is unique in the file. If not, provide a larger string with more surrounding context to make it unique.
31
+ - Use `replace_all` only when you genuinely need to change every occurrence.
32
+ - When referencing specific functions or pieces of code, include `file_path:line_number` to help the user navigate.
33
+
34
+ ## Git Safety Protocol
35
+
36
+ - NEVER update git config (user.name, user.email, etc.)
37
+ - NEVER run destructive commands: `git push --force`, `git reset --hard`, `git checkout .`, `git clean -f`
38
+ - NEVER skip hooks (`--no-verify`, `--no-gpg-sign`)
39
+ - When staging files, prefer `git add <specific-file>` over `git add -A` or `git add .`
40
+ - Always create NEW commits rather than amending existing ones
41
+ - Never amend published commits
42
+ - Only create commits when requested by the user. If unclear, ask first.
43
+
44
+ ## Error Handling
45
+
46
+ - Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees.
47
+ - Only validate at system boundaries (user input, external APIs).
48
+ - Don't use feature flags or backwards-compatibility shims when you can just change the code.
49
+
50
+ ## Security
51
+
52
+ - Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, and other OWASP top 10 vulnerabilities.
53
+ - If you notice insecure code, immediately fix it.
54
+ - Prioritize writing safe, secure, and correct code.
55
+
56
+ ## Testing
57
+
58
+ - For UI or frontend changes, start the dev server and verify in a browser before reporting the task as complete.
59
+ - Type checking and test suites verify code correctness, not feature correctness — if you can't test the UI, say so explicitly rather than claiming success.
60
+ - When the user asks you to run tests, do so and report the results.
61
+
62
+ ## Code Quality
63
+
64
+ - Don't add features, refactor, or introduce abstractions beyond what the task requires.
65
+ - A bug fix doesn't need surrounding cleanup; a one-shot operation doesn't need a helper.
66
+ - Three similar lines is better than a premature abstraction.
67
+ - No half-finished implementations either.
@@ -4,9 +4,10 @@ description: |
4
4
  Configure IM platform channels (Feishu, WeCom, Weixin) for openclacky.
5
5
  Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
6
6
  Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "channel config",
7
- "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor".
8
- Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor.
9
- argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor"
7
+ "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
8
+ "send message to weixin", "send message to feishu", "send message to wecom".
9
+ Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
10
+ argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
10
11
  allowed-tools:
11
12
  - Bash
12
13
  - Read
@@ -33,6 +34,7 @@ Configure IM platform channels for openclacky.
33
34
  | `channel disable feishu/wecom/weixin` | disable |
34
35
  | `channel reconfigure` | reconfigure |
35
36
  | `channel doctor` | doctor |
37
+ | `send <message> to weixin/feishu/wecom` | send |
36
38
 
37
39
  ---
38
40
 
@@ -98,7 +100,7 @@ ruby "SKILL_DIR/feishu_setup.rb"
98
100
  - The script completed successfully.
99
101
  - Config is already written to `~/.clacky/channels.yml`.
100
102
  - Tell the user: "✅ Feishu channel configured automatically! The channel is ready."
101
- - **Stop here do not proceed to manual steps.**
103
+ - **Skip Step 2 (manual fallback) and continue to Step 3.**
102
104
 
103
105
  **If exit code is non-0:**
104
106
  - Check stdout for the error message.
@@ -197,7 +199,61 @@ curl -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/in
197
199
  -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>"}'
198
200
  ```
199
201
 
200
- Check for `"code":0`. On success: "✅ Feishu channel configured."
202
+ Check for `"code":0`. On success: continue to Step 3 (below).
203
+
204
+ ##### Phase 9 — done
205
+
206
+ Step 2 ends here. **Continue to Step 3.**
207
+
208
+ ---
209
+
210
+ #### Step 3 — Optional: install Feishu CLI
211
+
212
+ Reach here from either Step 1 success or end of Step 2. Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
213
+
214
+ Call `request_user_feedback`:
215
+
216
+ zh:
217
+ ```json
218
+ {
219
+ "question": "是否要安装「飞书 CLI」?装好之后 AI 可以帮你操作飞书云文档、电子表格、多维表格、知识库、日历、任务等几乎全部飞书能力,不只是聊天,而是能\"做事\"。不装也 OK。",
220
+ "options": ["启用", "跳过"]
221
+ }
222
+ ```
223
+
224
+ en:
225
+ ```json
226
+ {
227
+ "question": "Install Feishu CLI? With it, the AI can operate Feishu Docs, Sheets, Bitable, Wiki, Calendar, Tasks and almost every Feishu capability — not just chat, but actually get things done. Skipping is fine.",
228
+ "options": ["Enable", "Skip"]
229
+ }
230
+ ```
231
+
232
+ If the user picks Skip, stop — setup is complete.
233
+
234
+ If the user picks Enable, run:
235
+
236
+ ```bash
237
+ lark-cli --version > /dev/null 2>&1 || npm install -g @larksuite/cli
238
+ echo -n "<APP_SECRET>" | lark-cli config init --app-id <APP_ID> --app-secret-stdin --brand feishu
239
+ npx -y skills add larksuite/cli -y -g
240
+ ruby "SKILL_DIR/import_lark_skills.rb"
241
+ lark-cli auth login --recommend
242
+ ```
243
+
244
+ The last command blocks up to 10 minutes waiting for browser authorization — make sure the runner's timeout is ≥ 600s.
245
+
246
+ Once you see the authorization URL in the command's stdout, tell the user (do **not** wait for a reply — the CLI's blocking poll will return on its own when authorization completes):
247
+ - zh: "请在浏览器中打开下方链接完成授权:\n<URL>"
248
+ - en: "Open this URL in your browser to authorize:\n<URL>"
249
+
250
+ **Do not kill and restart this command** — restarting invalidates the device code and breaks the link the user already opened. The "hang" is just polling; wait it out.
251
+
252
+ When `lark-cli auth login` returns successfully, tell the user:
253
+ - zh: "✅ 飞书 CLI 已就绪。"
254
+ - en: "✅ Feishu CLI is ready."
255
+
256
+ **Stop — setup is fully complete.**
201
257
 
202
258
  ---
203
259
 
@@ -347,6 +403,58 @@ Check each item, report ✅ / ❌ with remediation:
347
403
 
348
404
  ---
349
405
 
406
+ ## `send`
407
+
408
+ Proactively send a message to a user via an IM channel adapter.
409
+
410
+ ### Parse the request
411
+
412
+ Extract two things from the user's instruction:
413
+ - **platform** — one of `weixin`, `feishu`, `wecom`
414
+ - **message** — the text content to send
415
+
416
+ If the platform cannot be inferred, ask the user to clarify.
417
+
418
+ ### Step 1 — Resolve target user (optional)
419
+
420
+ If the user specified a `user_id`, use it directly.
421
+
422
+ Otherwise, list known users first:
423
+
424
+ ```bash
425
+ curl -s http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/users
426
+ ```
427
+
428
+ - If the list is **empty**: tell the user "No known users for `<platform>`. The target user must send at least one message to the bot before proactive messaging is possible." Stop here.
429
+ - If there is **exactly one** user: use it silently.
430
+ - If there are **multiple** users: show the list and ask which one to send to, unless the user already specified one.
431
+
432
+ ### Step 2 — Send the message
433
+
434
+ ```bash
435
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/send \
436
+ -H "Content-Type: application/json" \
437
+ -d '{"message": "<message>", "user_id": "<user_id>"}'
438
+ ```
439
+
440
+ **Response handling:**
441
+
442
+ | HTTP status | Meaning | Action |
443
+ |---|---|---|
444
+ | `200 { ok: true }` | Delivered | Tell user: "✅ Message sent to `<platform>`." |
445
+ | `400` platform not running | Adapter is stopped | Tell user the platform is not running and suggest `channel enable <platform>`. |
446
+ | `400` no context_token | Token missing | Explain: "The bot has no active session token for this user. Ask the user to send any message to the bot first, then retry." |
447
+ | `503` no known users | Nobody has messaged the bot | Same guidance as empty user list above. |
448
+ | Other error | Unexpected | Show the error message from the response body. |
449
+
450
+ ### Constraints & notes
451
+
452
+ - **Weixin (iLink protocol)**: Every outbound message requires a `context_token` that is obtained from the most recent inbound message from that user. The token is cached in memory and reset on server restart. If the server was restarted since the user last wrote, the token is gone and the send will fail — the user must message the bot again.
453
+ - **Feishu / WeCom**: No token required. As long as the adapter is running and the `user_id` / `chat_id` is valid, the message will be delivered.
454
+ - This feature is intended for **proactive notifications** (e.g. task completion, reminders). It is not a replacement for the normal reply flow triggered by inbound messages.
455
+
456
+ ---
457
+
350
458
  ## Security
351
459
 
352
460
  - Always mask secrets in output (last 4 chars only).
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+
6
+ # Import lark-cli's official Skills from ~/.agents/skills/lark-* into
7
+ # ~/.clacky/skills/lark-imports/<name>/.
8
+ #
9
+ # Background:
10
+ # lark-cli ships ~24 SKILL.md files (lark-doc, lark-sheets, lark-base, ...)
11
+ # that teach the agent how to use `lark-cli`. They are normally installed
12
+ # under ~/.agents/skills/lark-*, which openclacky's SkillLoader does NOT
13
+ # scan. This importer copies them into ~/.clacky/skills/lark-imports/ so
14
+ # they become discoverable via the standard skill description-matching
15
+ # mechanism.
16
+ #
17
+ # This is intentionally a small, dedicated importer (not a generic external
18
+ # skills tool) — it only handles the lark-cli case for the feishu channel
19
+ # setup flow. Failures are non-fatal: the bot itself remains functional even
20
+ # if Skills cannot be exposed.
21
+ #
22
+ # Usage:
23
+ # importer = Clacky::ChannelSetup::LarkSkillsImporter.new
24
+ # result = importer.run
25
+ # # result => { copied: 24, skipped: 0, errors: [] }
26
+
27
+ module Clacky
28
+ module ChannelSetup
29
+ class LarkSkillsImporter
30
+ DEFAULT_SOURCE_DIR = File.join(Dir.home, '.agents', 'skills')
31
+ DEFAULT_TARGET_DIR = File.join(Dir.home, '.clacky', 'skills', 'lark-imports')
32
+ SKILL_PREFIX = 'lark-'
33
+
34
+ # @param source_dir [String] directory containing lark-cli installed skills
35
+ # @param target_dir [String] destination under ~/.clacky/skills/
36
+ def initialize(source_dir: DEFAULT_SOURCE_DIR, target_dir: DEFAULT_TARGET_DIR)
37
+ @source_dir = Pathname.new(source_dir).expand_path
38
+ @target_dir = Pathname.new(target_dir).expand_path
39
+ end
40
+
41
+ # Run the import. Returns a result hash; never raises on per-skill errors.
42
+ # @return [Hash] { copied: Integer, skipped: Integer, errors: Array<String> }
43
+ def run
44
+ return { copied: 0, skipped: 0, errors: ["source not found: #{@source_dir}"] } unless @source_dir.directory?
45
+
46
+ skill_dirs = discover_lark_skills
47
+ return { copied: 0, skipped: 0, errors: [] } if skill_dirs.empty?
48
+
49
+ FileUtils.mkdir_p(@target_dir)
50
+
51
+ copied = 0
52
+ errors = []
53
+ skill_dirs.each do |src|
54
+ begin
55
+ copy_skill(src)
56
+ copied += 1
57
+ rescue StandardError => e
58
+ errors << "#{src.basename}: #{e.message}"
59
+ end
60
+ end
61
+
62
+ { copied: copied, skipped: 0, errors: errors }
63
+ end
64
+
65
+ # Discover candidate lark-* skill directories under @source_dir.
66
+ # A directory qualifies when it (a) starts with "lark-" and (b) contains a SKILL.md.
67
+ # @return [Array<Pathname>]
68
+ private def discover_lark_skills
69
+ @source_dir.children
70
+ .select { |p| p.directory? && p.basename.to_s.start_with?(SKILL_PREFIX) }
71
+ .select { |p| p.join('SKILL.md').exist? }
72
+ .sort_by { |p| p.basename.to_s }
73
+ end
74
+
75
+ # Copy a single skill directory into @target_dir, replacing any existing copy
76
+ # so re-runs always reflect the latest version.
77
+ # @param src [Pathname]
78
+ private def copy_skill(src)
79
+ dst = @target_dir.join(src.basename.to_s)
80
+ FileUtils.rm_rf(dst) if dst.exist?
81
+ FileUtils.mkdir_p(dst)
82
+ src.children.each { |child| FileUtils.cp_r(child, dst) }
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # CLI entry point — invoked by SKILL.md after the user opts in to lark-cli.
89
+ # Usage:
90
+ # ruby import_lark_skills.rb
91
+ # Prints a one-line summary; exits 0 even when nothing to copy (treat empty
92
+ # source as a soft skip — the script may run before `npx skills add`).
93
+ if $PROGRAM_NAME == __FILE__
94
+ result = Clacky::ChannelSetup::LarkSkillsImporter.new.run
95
+ puts "[lark-import] copied=#{result[:copied]} errors=#{result[:errors].size}"
96
+ result[:errors].each { |e| warn "[lark-import] #{e}" }
97
+ end
@@ -55,7 +55,7 @@ Example (English):
55
55
  > Let's take 30 seconds to personalize your experience — I'll ask just a couple of quick things.
56
56
 
57
57
  Example (Chinese):
58
- > 嗨!我是你的专属小龙虾一号
58
+ > 嗨!我是你的专属员工一号
59
59
  > 只需 30 秒完成个性化设置,我会问你两个简单问题。
60
60
 
61
61
  ### A.3. Ask the user to name the AI (card)
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: persist-memory
3
+ description: Persist information to long-term memory at ~/.clacky/memories/. Use when the user asks you to remember/note something, or when reviewing a finished conversation for facts worth keeping. Handles file naming, topic merging, frontmatter, and size limits.
4
+ fork_agent: true
5
+ user-invocable: false
6
+ auto_summarize: true
7
+ forbidden_tools:
8
+ - web_search
9
+ - web_fetch
10
+ - browser
11
+ ---
12
+
13
+ # Persist Memory Subagent
14
+
15
+ You are a **Memory Persistence Subagent** — a pure executor. The caller has already decided that something must be written. Your job is to write it correctly: pick the right file, merge with existing content, respect the size limit.
16
+
17
+ You do NOT decide whether to write. If the task description tells you to persist X, you persist X.
18
+
19
+ ## Existing Memory Files
20
+
21
+ The following memory files are pre-loaded for you — **do NOT re-scan the directory** with `terminal` or `file_reader`.
22
+
23
+ <%= memories_meta %>
24
+
25
+ Each file uses YAML frontmatter:
26
+
27
+ ```
28
+ ---
29
+ topic: <topic name>
30
+ description: <one-line description>
31
+ ---
32
+ <content in concise Markdown>
33
+ ```
34
+
35
+ ## Workflow
36
+
37
+ For each item to persist:
38
+
39
+ ### Step 1: Pick a target file
40
+
41
+ Scan the list above:
42
+
43
+ - **Matching topic exists** → read it with `file_reader(path: "~/.clacky/memories/<filename>")`, integrate the new info, drop stale parts, then `write` the updated version back.
44
+ - **No match** → create a new file at `~/.clacky/memories/<topic-slug>.md`.
45
+ - Slug: lowercase, hyphen-separated, descriptive (e.g. `deployment-target.md`, `code-style-preferences.md`).
46
+
47
+ ### Step 2: Write the file
48
+
49
+ Use the `write` tool. Always include the YAML frontmatter shown above.
50
+
51
+ ## Hard constraints (CRITICAL)
52
+
53
+ - Each file MUST stay under **4000 characters of content** (after the frontmatter).
54
+ - If merging would exceed this limit, remove the least important information — do NOT split into multiple files for the same topic.
55
+ - Write concise, factual Markdown — no fluff, no redundant headings.
56
+ - One topic per file. Don't bundle unrelated facts together.
57
+ - Do NOT use `terminal` or `file_reader` to list the memories directory — the list above is authoritative.
58
+
59
+ When done, briefly state what was written (e.g. "Updated deployment-target.md") or `No memory updates needed.` if the task description didn't actually require any writes.
@@ -146,12 +146,16 @@ module Clacky
146
146
  "default_model" => "kimi-k2.6",
147
147
  "models" => ["kimi-k2.6", "kimi-k2.5"],
148
148
  # Moonshot operates two regional endpoints with identical APIs & model
149
- # lineup — mainland China (.cn) and international (.ai). Kimi does not
150
- # distinguish pay-as-you-go vs coding-plan at the base_url level, so
151
- # only two variants are needed. Listing both here lets find_by_base_url
152
- # identify either one as provider "kimi", so downstream capability
153
- # checks, fallback chains, and provider-specific behaviours work
154
- # regardless of which endpoint the user configured.
149
+ # lineup — mainland China (.cn) and international (.ai). These are the
150
+ # pay-as-you-go Open Platform endpoints; the subscription-billed
151
+ # Coding Plan lives at api.kimi.com/coding with the unified
152
+ # `kimi-for-coding` model alias and is exposed as a separate
153
+ # top-level "kimi-coding" preset (different domain, distinct billing
154
+ # model, marketed by Moonshot as the standalone Kimi Code product).
155
+ # Listing both PAYG variants here lets find_by_base_url identify
156
+ # either one as provider "kimi", so downstream capability checks,
157
+ # fallback chains, and provider-specific behaviours work regardless
158
+ # of which endpoint the user configured.
155
159
  "endpoint_variants" => [
156
160
  { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
157
161
  { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
@@ -161,6 +165,44 @@ module Clacky
161
165
  "website_url" => "https://platform.moonshot.cn/console/api-keys"
162
166
  }.freeze,
163
167
 
168
+ "kimi-coding" => {
169
+ "name" => "Kimi Code (Coding Plan)",
170
+ # Subscription-billed Kimi Code endpoint — separate product from the
171
+ # PAYG Moonshot Open Platform (api.moonshot.cn/v1 / .ai/v1). Uses the
172
+ # unified `kimi-for-coding` model alias which the Coding Plan backend
173
+ # routes to the appropriate K2 variant (Kimi-k2.6 today; 262K context,
174
+ # 32K max output, supports vision/video/reasoning).
175
+ #
176
+ # Why anthropic-messages: Moonshot exposes the Coding Plan via two
177
+ # URLs on the same domain — an Anthropic-format endpoint at
178
+ # api.kimi.com/coding/ (used by Claude Code via ANTHROPIC_BASE_URL)
179
+ # and an OpenAI-compatible endpoint at api.kimi.com/coding/v1 (used
180
+ # by Roo Code etc.). We route through anthropic-messages so
181
+ # cache_control fields round-trip byte-for-byte (the OpenAI shim is
182
+ # lossy for cache_control semantics — see OpenRouter preset above
183
+ # for the same reason). Verified against the live endpoint: response
184
+ # payload includes cache_creation_input_tokens / cache_read_input_tokens,
185
+ # so the cache layer is real on this backend.
186
+ #
187
+ # User-Agent gate: this endpoint enforces a UA-prefix whitelist
188
+ # limited to first-party coding agents (Kimi CLI, Claude Code, Roo
189
+ # Code, Kilo Code, ...). Requests carrying openclacky's default
190
+ # Faraday UA are rejected with HTTP 403 access_terminated_error.
191
+ # Client#anthropic_connection injects a Claude Code-shaped UA when
192
+ # @provider_id == "kimi-coding" — see the comment in client.rb for
193
+ # the policy rationale.
194
+ #
195
+ # Source: https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html
196
+ "base_url" => "https://api.kimi.com/coding",
197
+ "api" => "anthropic-messages",
198
+ "default_model" => "kimi-for-coding",
199
+ "models" => ["kimi-for-coding"],
200
+ # K2.6 backend behind the alias is multimodal (image + video input,
201
+ # reasoning). Same vision capability as the PAYG kimi preset.
202
+ "capabilities" => { "vision" => true }.freeze,
203
+ "website_url" => "https://www.kimi.com/code"
204
+ }.freeze,
205
+
164
206
  "anthropic" => {
165
207
  "name" => "Anthropic (Claude)",
166
208
  "base_url" => "https://api.anthropic.com",
@@ -393,6 +393,13 @@ module Clacky
393
393
  @ctx_mutex.synchronize { @context_tokens[user_id] }
394
394
  end
395
395
 
396
+ # Return all user IDs for which we have a cached context_token.
397
+ # Used by ChannelManager#known_users so callers can enumerate
398
+ # users reachable for proactive messaging.
399
+ def context_token_user_ids
400
+ @ctx_mutex.synchronize { @context_tokens.keys.dup }
401
+ end
402
+
396
403
  # Split text into ≤2000 Unicode character chunks per iLink protocol recommendation.
397
404
  # Priority: split at \n\n, then \n, then space, then hard cut.
398
405
  def split_message(text, limit: 2000)
@@ -77,6 +77,74 @@ module Clacky
77
77
  @mutex.synchronize { @adapters.map(&:platform_id) }
78
78
  end
79
79
 
80
+ # Proactively send a message to a user on the given platform.
81
+ #
82
+ # For Weixin (iLink protocol) a context_token is required for every outbound
83
+ # message. This method looks up the most-recently cached token for user_id.
84
+ # If no token is found the message cannot be delivered and nil is returned.
85
+ #
86
+ # For Feishu and WeCom the chat_id / user_id is sufficient — no token needed.
87
+ #
88
+ # @param platform [Symbol, String] e.g. :weixin, :feishu, :wecom
89
+ # @param user_id [String] IM user identifier
90
+ # @param message [String] plain-text (or markdown) message to send
91
+ # @return [Hash, nil] adapter result hash, or nil on failure
92
+ def send_to_user(platform, user_id, message)
93
+ platform = platform.to_sym
94
+ adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
95
+
96
+ unless adapter
97
+ Clacky::Logger.warn("[ChannelManager] send_to_user: no running adapter for :#{platform}")
98
+ return nil
99
+ end
100
+
101
+ Clacky::Logger.info("[ChannelManager] send_to_user :#{platform} → #{user_id}")
102
+ adapter.send_text(user_id, message)
103
+ rescue StandardError => e
104
+ Clacky::Logger.error("[ChannelManager] send_to_user failed: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ # Return a list of known user IDs for the given platform.
109
+ # Collected from every message that has been processed since the server started.
110
+ # Weixin stores context_tokens keyed by user_id; feishu/wecom track chat_ids
111
+ # via the session binding table in the registry.
112
+ #
113
+ # @param platform [Symbol, String]
114
+ # @return [Array<String>]
115
+ def known_users(platform)
116
+ platform = platform.to_sym
117
+ adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
118
+ return [] unless adapter
119
+
120
+ # Weixin adapter exposes @context_tokens whose keys are user_ids
121
+ if adapter.respond_to?(:context_token_user_ids)
122
+ return adapter.context_token_user_ids
123
+ end
124
+
125
+ # Fallback: scan session registry for channel_keys matching this platform.
126
+ # Key formats depend on binding_mode:
127
+ # :user → "platform:user:USER_ID"
128
+ # :chat → "platform:chat:CHAT_ID"
129
+ # :chat_user → "platform:chat:CHAT_ID:user:USER_ID"
130
+ #
131
+ # For send_text we need the chat_id (Feishu/WeCom use chat_id as the
132
+ # receive_id for outbound messages), so we extract the chat portion.
133
+ prefix = "#{platform}:"
134
+ ids = []
135
+ @registry.list.each do |summary|
136
+ @registry.with_session(summary[:id]) do |s|
137
+ (s[:channel_keys] || []).each do |key|
138
+ next unless key.start_with?(prefix)
139
+
140
+ remainder = key.sub(prefix, "") # e.g. "chat:OC_ID:user:OU_ID" or "user:UID" or "chat:CID"
141
+ ids << extract_chat_id(remainder)
142
+ end
143
+ end
144
+ end
145
+ ids.compact.uniq
146
+ end
147
+
80
148
  # Hot-reload a single platform adapter with updated config.
81
149
  # Stops the existing adapter (if running), then starts a new one if enabled.
82
150
  # @param platform [Symbol]
@@ -367,6 +435,29 @@ module Clacky
367
435
  end
368
436
  end
369
437
 
438
+ # Extract the chat_id from the remainder of a channel_key (after removing "platform:" prefix).
439
+ #
440
+ # Possible formats:
441
+ # "chat:CHAT_ID:user:USER_ID" → CHAT_ID (chat_user mode)
442
+ # "chat:CHAT_ID" → CHAT_ID (chat mode)
443
+ # "user:USER_ID" → USER_ID (user mode — use user_id as fallback)
444
+ #
445
+ # For Feishu/WeCom send_text, the chat_id is what's needed as receive_id.
446
+ private def extract_chat_id(remainder)
447
+ if remainder.start_with?("chat:")
448
+ # "chat:CHAT_ID:user:USER_ID" or "chat:CHAT_ID"
449
+ after_chat = remainder.sub("chat:", "")
450
+ # If there's a ":user:" segment, strip it and everything after
451
+ idx = after_chat.index(":user:")
452
+ idx ? after_chat[0...idx] : after_chat
453
+ elsif remainder.start_with?("user:")
454
+ # user-only mode: no chat_id available, use user_id
455
+ remainder.sub("user:", "")
456
+ else
457
+ remainder
458
+ end
459
+ end
460
+
370
461
  def safe_stop_adapter(adapter)
371
462
  adapter.stop
372
463
  rescue StandardError => e
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "socket"
5
+
6
+ module Clacky
7
+ module Server
8
+ # Discover locally-running Clacky server(s) by scanning PID files
9
+ # written by Master at /tmp/clacky-master-<port>.pid.
10
+ #
11
+ # Used by the CLI (bare `clacky agent` mode) to auto-detect a sibling
12
+ # server process, so skills that call back into the server (channels,
13
+ # browser, scheduler, etc.) can work without the user manually setting
14
+ # CLACKY_SERVER_HOST / CLACKY_SERVER_PORT.
15
+ #
16
+ # Fast and side-effect free: only reads files and sends signal 0.
17
+ # Does NOT probe the TCP port (avoids false positives from stale files
18
+ # but also avoids noisy connection attempts).
19
+ module Discover
20
+ PID_FILE_GLOB = File.join(Dir.tmpdir, "clacky-master-*.pid").freeze
21
+ PID_FILE_REGEX = /clacky-master-(\d+)\.pid\z/.freeze
22
+
23
+ module_function
24
+
25
+ # Find the first live Clacky server on this machine.
26
+ #
27
+ # @return [Hash, nil] { host: "127.0.0.1", port: Integer, pid: Integer } or nil
28
+ def find_local
29
+ find_all_local.first
30
+ end
31
+
32
+ # Find all live Clacky servers on this machine.
33
+ #
34
+ # A PID file is considered "live" when:
35
+ # 1. The filename matches clacky-master-<port>.pid
36
+ # 2. Its contents parse as a positive integer
37
+ # 3. Process.kill(0, pid) confirms the PID is alive
38
+ #
39
+ # Stale PID files (process gone) are silently ignored. We do NOT
40
+ # delete them here — that's the owning server's responsibility.
41
+ #
42
+ # @return [Array<Hash>] sorted by port ascending
43
+ def find_all_local
44
+ Dir.glob(PID_FILE_GLOB).filter_map do |path|
45
+ m = path.match(PID_FILE_REGEX)
46
+ next nil unless m
47
+
48
+ port = m[1].to_i
49
+ next nil if port <= 0
50
+
51
+ pid_str = File.read(path).strip rescue nil
52
+ next nil if pid_str.nil? || pid_str.empty?
53
+
54
+ pid = pid_str.to_i
55
+ next nil if pid <= 0
56
+
57
+ next nil unless process_alive?(pid)
58
+
59
+ { host: "127.0.0.1", port: port, pid: pid }
60
+ end.sort_by { |e| e[:port] }
61
+ end
62
+
63
+ # @param pid [Integer]
64
+ # @return [Boolean]
65
+ def process_alive?(pid)
66
+ Process.kill(0, pid)
67
+ true
68
+ rescue Errno::ESRCH, Errno::EPERM
69
+ # ESRCH — no such process; EPERM — process exists but owned by someone else
70
+ # (still technically alive, but we can't safely assume it's "our" server)
71
+ false
72
+ rescue StandardError
73
+ false
74
+ end
75
+ end
76
+ end
77
+ end