openclacky 1.3.2 → 1.3.3
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 +4 -4
- data/CHANGELOG.md +28 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/lib/clacky/agent/session_serializer.rb +49 -5
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +12 -1
- data/lib/clacky/agent_config.rb +14 -2
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/media/output_dir.rb +43 -0
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/server/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +497 -12
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +473 -60
- data/lib/clacky/web/app.js +30 -7
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/core/aside.js +112 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +303 -0
- data/lib/clacky/web/features/skills/view.js +550 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +56 -6
- data/lib/clacky/web/index.html +117 -58
- data/lib/clacky/web/sessions.js +221 -25
- data/lib/clacky/web/settings.js +118 -22
- data/lib/clacky/web/skills.js +3 -863
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky.rb +1 -0
- metadata +45 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -373
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -316
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: extend-openclacky
|
|
3
|
-
description: Customize, fix, override or extend openclacky itself —
|
|
3
|
+
description: Customize, fix, override or extend openclacky itself — change a built-in tool's behavior, intercept/audit/block tool calls, plug in a new IM channel (Slack, in-house IM…), or add UI to the Web UI (panel, button, settings tab). Trigger on "patch openclacky", "block dangerous commands", "audit tool use", "add Slack channel", "extend the web ui", "改 openclacky 内置", "拦截工具调用", "扩展 web 界面". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Extending Openclacky
|
|
7
7
|
|
|
8
|
-
Openclacky ships
|
|
8
|
+
Openclacky ships four official extension mechanisms that survive `gem update` and never require editing the gem source.
|
|
9
9
|
**Never tell the user to `bundle show openclacky` and edit the gem — always use one of these.**
|
|
10
10
|
|
|
11
11
|
## Pick the right mechanism
|
|
@@ -15,6 +15,7 @@ Openclacky ships three official extension mechanisms that survive `gem update` a
|
|
|
15
15
|
| Change behavior of an **existing method** in openclacky (e.g. `WebSearch#execute` timeout, fix a bug in a built-in tool) | **Patch** | `clacky patch_new <id> "Const#method" -d "<desc>"` | `clacky patch_verify` |
|
|
16
16
|
| **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) — no Ruby needed | **Shell Hook** | `clacky hook_new <id> -e <event>` | `clacky hook_verify` |
|
|
17
17
|
| Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook…) | **Channel Adapter** | `clacky channel_new <platform_id>` | `clacky channel_verify` |
|
|
18
|
+
| Add UI to the **Web UI** (custom panel, header button, settings tab, visualize data) | **Web UI Extension** | drop a `.js` file in `~/.clacky/webui_ext/` | reload page; `Clacky.ext.slots()` in console; `?pure=true` to escape |
|
|
18
19
|
|
|
19
20
|
## Authoritative documentation
|
|
20
21
|
|
|
@@ -23,14 +24,15 @@ Each mechanism has a full reference doc — read the relevant one with `web_fetc
|
|
|
23
24
|
- Patches → https://www.openclacky.com/docs/extend-patches
|
|
24
25
|
- Shell Hooks → https://www.openclacky.com/docs/extend-shell-hooks
|
|
25
26
|
- Channel Adapters → https://www.openclacky.com/docs/extend-channel-adapter
|
|
27
|
+
- Web UI Extensions → https://www.openclacky.com/docs/extend-webui
|
|
26
28
|
|
|
27
29
|
## Execution playbook
|
|
28
30
|
|
|
29
31
|
1. **Identify** which mechanism fits (use the table above; ask if genuinely ambiguous).
|
|
30
32
|
2. **Read the doc** for that mechanism with `web_fetch`. Don't guess fields, hook events, or required methods — the doc is the contract.
|
|
31
|
-
3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta.
|
|
33
|
+
3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta. *(Web UI Extensions have no scaffold — just create a `.js` file under `~/.clacky/webui_ext/`; the doc shows the `Clacky.ext` contract.)*
|
|
32
34
|
4. **Edit** the generated file to implement the user's intent. Keep generated meta fields (`target`, `event`, `platform_id`, the `Clacky::ChannelRegistry.register(...)` line, etc.) intact unless the doc says otherwise.
|
|
33
|
-
5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim.
|
|
35
|
+
5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim. *(Web UI Extensions have no verify command — reload the page and confirm the slot rendered; if anything breaks, `?pure=true` disables all extensions instantly.)*
|
|
34
36
|
|
|
35
37
|
## When NOT to use this skill
|
|
36
38
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: media-gen
|
|
3
|
-
description: 'Generate images, videos, or audio (text-to-speech) in the current task. Use whenever the user asks to create/generate/produce a picture / image / illustration / cover / poster / icon / artwork, a video / clip / animation, or speech / voiceover / narration / TTS — e.g.
|
|
3
|
+
description: 'Generate or edit images, videos, or audio (text-to-speech) in the current task. Use whenever the user asks to create/generate/produce or edit/modify a picture / image / illustration / cover / poster / icon / artwork, a video / clip / animation, or speech / voiceover / narration / TTS — e.g. generate image, draw, design a cover, edit this image, change the background, text-to-video, generate speech; 画一张, 配图, 编辑图片, 改图, 换背景, 做个视频, 配音, 文字转语音. Also use when a document (slides, poster, README hero) needs an inline image.'
|
|
4
4
|
disable-model-invocation: false
|
|
5
5
|
user-invocable: true
|
|
6
6
|
always-show: true
|
|
@@ -8,7 +8,7 @@ always-show: true
|
|
|
8
8
|
|
|
9
9
|
# media-gen
|
|
10
10
|
|
|
11
|
-
Generate images on demand by calling the local Clacky HTTP server, which dispatches to whichever image-generation model the user configured (`type=image` in their model settings).
|
|
11
|
+
Generate **and edit** images on demand by calling the local Clacky HTTP server, which dispatches to whichever image-generation model the user configured (`type=image` in their model settings). Editing (image-in → image-out) works with any image model that accepts image input — most current ones do.
|
|
12
12
|
|
|
13
13
|
## Endpoint
|
|
14
14
|
|
|
@@ -73,7 +73,32 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
|
|
|
73
73
|
|----------------|----------|-------------------------------------|-------|
|
|
74
74
|
| `prompt` | yes | string | Be detailed and concrete. See prompt tips below. |
|
|
75
75
|
| `aspect_ratio` | no | `landscape` / `square` / `portrait` | Defaults to `landscape`. |
|
|
76
|
-
| `output_dir` | no | absolute path |
|
|
76
|
+
| `output_dir` | no | absolute path | Per-call override. When omitted, falls back to the user's `media_output_dir` setting (Settings → Models → Media Output Directory), then to the working directory. The image is always saved into the `assets/generated/` subdirectory of whichever path is used. |
|
|
77
|
+
| `image` | no | file path / base64 / data URL | A single input image to **edit**. Triggers image-edit mode (see below). |
|
|
78
|
+
| `images` | no | array of the above | Multiple input images for a multi-image edit. Takes precedence over `image`. |
|
|
79
|
+
|
|
80
|
+
### Editing an existing image
|
|
81
|
+
|
|
82
|
+
To edit instead of generate from scratch, pass the existing image as `image`
|
|
83
|
+
(a local file path is easiest — the skill reads and encodes it for you) plus a
|
|
84
|
+
`prompt` describing the change. The configured image model receives the
|
|
85
|
+
image alongside the prompt and returns an edited result.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/image \
|
|
89
|
+
-H "Content-Type: application/json" \
|
|
90
|
+
-d '{
|
|
91
|
+
"prompt": "change the background to a starry night sky, keep the cat unchanged",
|
|
92
|
+
"image": "/abs/path/to/input.png"
|
|
93
|
+
}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- The result is a **new** edited image saved under `assets/generated/` — the
|
|
97
|
+
original file is never modified in place.
|
|
98
|
+
- For combining several inputs (e.g. "put the product from image 1 onto the
|
|
99
|
+
background from image 2"), pass them as `images: ["/path/a.png", "/path/b.png"]`
|
|
100
|
+
and describe the composition in the prompt.
|
|
101
|
+
- Same speed/concurrency rules apply: editing is as slow as generation, one at a time.
|
|
77
102
|
|
|
78
103
|
### Response shape (success)
|
|
79
104
|
|
|
@@ -81,7 +106,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
|
|
|
81
106
|
{
|
|
82
107
|
"success": true,
|
|
83
108
|
"image": "/abs/path/to/working_dir/assets/generated/img_20260525_011820_a1b2c3d4.png",
|
|
84
|
-
"model": "
|
|
109
|
+
"model": "<the configured image model>",
|
|
85
110
|
"provider": "openclacky",
|
|
86
111
|
"prompt": "A clean, modern hero illustration ...",
|
|
87
112
|
"aspect_ratio": "landscape",
|
|
@@ -145,7 +170,6 @@ When the user gives a vague request like "给我配张图", ask one clarifying q
|
|
|
145
170
|
|
|
146
171
|
## When NOT to use this skill
|
|
147
172
|
|
|
148
|
-
- The user asks to **edit** an existing image (this skill is text-to-image only today)
|
|
149
173
|
- The user wants a **diagram / chart** with specific data — use a charting library (matplotlib, mermaid, etc.) instead; image gen is for illustrations, not data viz
|
|
150
174
|
- The user asks for **screenshots** of real software — use the browser tool
|
|
151
175
|
|
|
@@ -190,7 +214,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
|
|
|
190
214
|
| `aspect_ratio` | no | `landscape` / `portrait` | Defaults to `landscape` (16:9). |
|
|
191
215
|
| `duration_seconds` | no | 4–8 | Defaults to 8. |
|
|
192
216
|
| `image` | no | `{ "b64_json": "...", "mime_type": "image/png" }` | Optional first frame for image-to-video. |
|
|
193
|
-
| `output_dir` | no | absolute path | MP4 saved
|
|
217
|
+
| `output_dir` | no | absolute path | Per-call override; same fallback chain as `image` (user setting → cwd). MP4 saved into the `assets/generated/` subdirectory of whichever path is used. |
|
|
194
218
|
|
|
195
219
|
### Response (success)
|
|
196
220
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
+
require "base64"
|
|
5
6
|
require_relative "base"
|
|
6
7
|
|
|
7
8
|
module Clacky
|
|
@@ -28,7 +29,7 @@ module Clacky
|
|
|
28
29
|
VIDEO_ASPECTS = %w[landscape portrait].freeze
|
|
29
30
|
DEFAULT_VIDEO_DURATION = 8
|
|
30
31
|
|
|
31
|
-
def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, **_kwargs)
|
|
32
|
+
def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, image: nil, images: nil, **_kwargs)
|
|
32
33
|
provider_id = Clacky::Providers.find_by_base_url(@base_url) || "custom"
|
|
33
34
|
aspect = ASPECT_TO_SIZE.key?(aspect_ratio) ? aspect_ratio : DEFAULT_ASPECT
|
|
34
35
|
size = ASPECT_TO_SIZE[aspect]
|
|
@@ -52,6 +53,18 @@ module Clacky
|
|
|
52
53
|
)
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
begin
|
|
57
|
+
input_images = normalize_input_images(image, images)
|
|
58
|
+
rescue ArgumentError => e
|
|
59
|
+
return error_response(
|
|
60
|
+
error: e.message,
|
|
61
|
+
error_type: "invalid_argument",
|
|
62
|
+
provider: provider_id,
|
|
63
|
+
prompt: prompt,
|
|
64
|
+
aspect_ratio: aspect
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
55
68
|
payload = { model: @model, n: n }
|
|
56
69
|
if gemini_family?(@model)
|
|
57
70
|
# Gemini image models (routed via openclacky / openrouter gateway)
|
|
@@ -64,6 +77,11 @@ module Clacky
|
|
|
64
77
|
payload[:size] = size
|
|
65
78
|
end
|
|
66
79
|
|
|
80
|
+
# With input image(s) this becomes an edit: the gateway forwards them
|
|
81
|
+
# to the model alongside the prompt. Sent as `images` (array) so
|
|
82
|
+
# multi-image edits work; the gateway also accepts a single `image`.
|
|
83
|
+
payload[:images] = input_images unless input_images.empty?
|
|
84
|
+
|
|
67
85
|
begin
|
|
68
86
|
response = connection.post("images/generations") do |req|
|
|
69
87
|
req.headers["Content-Type"] = "application/json"
|
|
@@ -306,6 +324,51 @@ module Clacky
|
|
|
306
324
|
model_name.to_s.match?(/gemini|imagen/i)
|
|
307
325
|
end
|
|
308
326
|
|
|
327
|
+
# Normalise the optional image/edit inputs into an array of data URLs
|
|
328
|
+
# ("data:<mime>;base64,<payload>") the gateway understands. Each input
|
|
329
|
+
# may be a local file path, a data URL, or a bare base64 string.
|
|
330
|
+
# `images` (array or single) takes precedence over `image`.
|
|
331
|
+
# Raises ArgumentError on a missing file or undecodable input.
|
|
332
|
+
private def normalize_input_images(image, images)
|
|
333
|
+
raw = images.nil? ? image : images
|
|
334
|
+
return [] if raw.nil?
|
|
335
|
+
|
|
336
|
+
list = raw.is_a?(Array) ? raw : [raw]
|
|
337
|
+
list.filter_map do |item|
|
|
338
|
+
s = item.to_s.strip
|
|
339
|
+
next if s.empty?
|
|
340
|
+
to_data_url(s)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
private def to_data_url(input)
|
|
345
|
+
return input if input.start_with?("data:")
|
|
346
|
+
|
|
347
|
+
# A filesystem path → read and encode.
|
|
348
|
+
if File.file?(input)
|
|
349
|
+
bytes = File.binread(input)
|
|
350
|
+
mime = mime_for_path(input)
|
|
351
|
+
return "data:#{mime};base64,#{Base64.strict_encode64(bytes)}"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Otherwise treat as a bare base64 payload; validate it decodes.
|
|
355
|
+
begin
|
|
356
|
+
Base64.strict_decode64(input)
|
|
357
|
+
rescue ArgumentError
|
|
358
|
+
raise ArgumentError, "input image is neither an existing file path nor valid base64"
|
|
359
|
+
end
|
|
360
|
+
"data:image/png;base64,#{input}"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
private def mime_for_path(path)
|
|
364
|
+
case File.extname(path).downcase
|
|
365
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
366
|
+
when ".webp" then "image/webp"
|
|
367
|
+
when ".gif" then "image/gif"
|
|
368
|
+
else "image/png"
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
309
372
|
# base_url is taken verbatim from PRESETS (each provider already
|
|
310
373
|
# includes the API version segment when needed). We only ensure a
|
|
311
374
|
# trailing slash so Faraday's relative-path join behaves.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Media
|
|
5
|
+
# Resolves the on-disk root for generated media files (images, videos,
|
|
6
|
+
# audio) according to a fixed precedence:
|
|
7
|
+
#
|
|
8
|
+
# 1. `param` — explicit `output_dir` from the API caller.
|
|
9
|
+
# Highest priority; lets a single call land
|
|
10
|
+
# somewhere specific (e.g. a doc's project root).
|
|
11
|
+
# 2. `configured` — user setting from AgentConfig#media_output_dir.
|
|
12
|
+
# Set via Settings → Models → Media Output Directory.
|
|
13
|
+
# 3. `fallback` — process default; preserves legacy behavior for
|
|
14
|
+
# configs that have neither key set.
|
|
15
|
+
#
|
|
16
|
+
# Pure function on purpose: callers (HTTP handlers) read the configured
|
|
17
|
+
# value off AgentConfig and inject it here. Keeps this helper trivially
|
|
18
|
+
# unit-testable and free of global state.
|
|
19
|
+
#
|
|
20
|
+
# The final on-disk path is `<resolved>/assets/generated/<file>` —
|
|
21
|
+
# the `assets/generated/` suffix is appended by Media::Base#save_*
|
|
22
|
+
# for stable relative-path semantics across markdown / slide outputs,
|
|
23
|
+
# and is intentionally not configurable here.
|
|
24
|
+
module OutputDir
|
|
25
|
+
# @param param [String, nil] explicit per-call override
|
|
26
|
+
# @param configured [String, nil] user-configured default
|
|
27
|
+
# @param fallback [String] last-resort default (defaults to Dir.pwd)
|
|
28
|
+
# @return [String] absolute or `~`-prefixed path; the
|
|
29
|
+
# caller's File.join with "assets/generated/" handles `~` via the
|
|
30
|
+
# surrounding FileUtils.mkdir_p call only when expanded — for safety
|
|
31
|
+
# we expand `~` here so downstream sees an absolute path.
|
|
32
|
+
def self.resolve(param:, configured:, fallback: Dir.pwd)
|
|
33
|
+
chosen = first_present(param, configured) || fallback
|
|
34
|
+
File.expand_path(chosen.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @api private
|
|
38
|
+
def self.first_present(*candidates)
|
|
39
|
+
candidates.find { |c| c.is_a?(String) && !c.strip.empty? }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -85,6 +85,15 @@ module Clacky
|
|
|
85
85
|
self
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Truncate history starting from the user message with the given created_at timestamp.
|
|
89
|
+
# Removes that message and everything after it. Returns self.
|
|
90
|
+
def truncate_from_created_at(created_at)
|
|
91
|
+
idx = @messages.index { |m| m[:role] == "user" && m[:created_at].to_s == created_at.to_s }
|
|
92
|
+
return self unless idx
|
|
93
|
+
|
|
94
|
+
truncate_from(idx)
|
|
95
|
+
end
|
|
96
|
+
|
|
88
97
|
# Roll back the history to just before the given message object.
|
|
89
98
|
# Removes the message and anything appended after it.
|
|
90
99
|
# Used to undo a failed speculative append (e.g. compression message that errored).
|
|
@@ -43,6 +43,8 @@ module Clacky
|
|
|
43
43
|
@running = false
|
|
44
44
|
@mutex = Mutex.new
|
|
45
45
|
@session_counters = Hash.new(0)
|
|
46
|
+
@dedup_mutex = Mutex.new
|
|
47
|
+
@last_message = {} # channel_key => [digest, monotonic_time]
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# Start all enabled adapters in background threads. Non-blocking.
|
|
@@ -242,6 +244,16 @@ module Clacky
|
|
|
242
244
|
files = event[:files] || []
|
|
243
245
|
return if (text.nil? || text.empty?) && files.empty?
|
|
244
246
|
|
|
247
|
+
# Safety net against adapter-side message storms: drop an identical message
|
|
248
|
+
# repeated on the same channel within a short window. A real user never
|
|
249
|
+
# sends the exact same text+files twice within DEDUP_WINDOW seconds, but a
|
|
250
|
+
# misbehaving adapter (or noisy IM system messages) can, which would
|
|
251
|
+
# otherwise spin the interrupt→restart loop below indefinitely.
|
|
252
|
+
if duplicate_message?(event, text, files)
|
|
253
|
+
Clacky::Logger.info("[ChannelManager] Dropping duplicate message on #{channel_key(event)} within #{DEDUP_WINDOW}s")
|
|
254
|
+
return
|
|
255
|
+
end
|
|
256
|
+
|
|
245
257
|
# Handle built-in commands
|
|
246
258
|
if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
|
|
247
259
|
handle_command(adapter, event, text)
|
|
@@ -749,6 +761,20 @@ module Clacky
|
|
|
749
761
|
adapter.send_text(chat_id, "Recent sessions:\n#{lines.join("\n")}\n\nUse `/bind <n>` to switch.")
|
|
750
762
|
end
|
|
751
763
|
|
|
764
|
+
DEDUP_WINDOW = 2.0 # seconds; identical messages on the same channel within this window are dropped
|
|
765
|
+
|
|
766
|
+
private def duplicate_message?(event, text, files)
|
|
767
|
+
key = channel_key(event)
|
|
768
|
+
digest = [text, files.map { |f| f.is_a?(Hash) ? f[:name] : f.to_s }].hash
|
|
769
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
770
|
+
|
|
771
|
+
@dedup_mutex.synchronize do
|
|
772
|
+
prev_digest, prev_time = @last_message[key]
|
|
773
|
+
@last_message[key] = [digest, now]
|
|
774
|
+
!prev_digest.nil? && prev_digest == digest && (now - prev_time) < DEDUP_WINDOW
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
752
778
|
def channel_key(event)
|
|
753
779
|
platform = event[:platform].to_s
|
|
754
780
|
case @binding_mode
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Server
|
|
7
|
+
# Read-mostly git operations scoped to a session's working directory, backing
|
|
8
|
+
# the official "git" WebUI panel. Commands run with explicit argv (no shell),
|
|
9
|
+
# so user-supplied values (paths, messages) cannot inject. Write operations
|
|
10
|
+
# are limited to a guarded `commit`; history-rewriting / remote-mutating
|
|
11
|
+
# commands are never exposed here.
|
|
12
|
+
module GitPanel
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Run a git subcommand in `dir` with argv-style args (no shell). Returns
|
|
16
|
+
# [stdout, stderr, success_bool]. Never raises on git failure.
|
|
17
|
+
def git(dir, *args)
|
|
18
|
+
out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
|
|
19
|
+
[out, err, status.success?]
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
["", e.message, false]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether `dir` is inside a git work tree.
|
|
25
|
+
def repo?(dir)
|
|
26
|
+
out, _err, ok = git(dir, "rev-parse", "--is-inside-work-tree")
|
|
27
|
+
ok && out.strip == "true"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# { branch:, ahead:, behind:, files: [{ path:, x:, y:, staged:, untracked: }] }
|
|
31
|
+
# Parsed from `git status --porcelain=v2 --branch`.
|
|
32
|
+
def status(dir)
|
|
33
|
+
out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch")
|
|
34
|
+
return { branch: nil, files: [] } unless ok
|
|
35
|
+
|
|
36
|
+
branch = nil
|
|
37
|
+
ahead = behind = 0
|
|
38
|
+
files = []
|
|
39
|
+
out.each_line do |line|
|
|
40
|
+
line = line.chomp
|
|
41
|
+
if line.start_with?("# branch.head ")
|
|
42
|
+
branch = line.sub("# branch.head ", "")
|
|
43
|
+
elsif line.start_with?("# branch.ab ")
|
|
44
|
+
m = line.match(/\+(\d+) -(\d+)/)
|
|
45
|
+
ahead, behind = m[1].to_i, m[2].to_i if m
|
|
46
|
+
elsif line.start_with?("1 ", "2 ")
|
|
47
|
+
xy = line.split(" ")[1]
|
|
48
|
+
path = line.split(" ", 9).last
|
|
49
|
+
files << { path: path, x: xy[0], y: xy[1],
|
|
50
|
+
staged: xy[0] != ".", untracked: false }
|
|
51
|
+
elsif line.start_with?("? ")
|
|
52
|
+
files << { path: line.sub("? ", ""), x: "?", y: "?",
|
|
53
|
+
staged: false, untracked: true }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
{ branch: branch, ahead: ahead, behind: behind, files: files }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Unified diff. `file` (optional, relative) limits to one path; omitted =
|
|
60
|
+
# whole working tree (tracked changes). `--` guards path from being read
|
|
61
|
+
# as an option.
|
|
62
|
+
def diff(dir, file: nil)
|
|
63
|
+
args = ["diff"]
|
|
64
|
+
args += ["--", file] if file && !file.empty?
|
|
65
|
+
out, _err, _ok = git(dir, *args)
|
|
66
|
+
out
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Recent commits: [{ hash:, short:, author:, date:, subject: }].
|
|
70
|
+
def log(dir, limit: 50)
|
|
71
|
+
limit = limit.to_i.clamp(1, 200)
|
|
72
|
+
fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s"
|
|
73
|
+
out, _err, ok = git(dir, "log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}")
|
|
74
|
+
return [] unless ok
|
|
75
|
+
|
|
76
|
+
out.each_line.filter_map do |line|
|
|
77
|
+
h, short, author, date, subject = line.chomp.split("\x1f")
|
|
78
|
+
next unless h
|
|
79
|
+
{ hash: h, short: short, author: author, date: date, subject: subject }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# [{ name:, current: bool }] from `git branch`.
|
|
84
|
+
def branches(dir)
|
|
85
|
+
out, _err, ok = git(dir, "branch", "--format=%(refname:short)%00%(HEAD)")
|
|
86
|
+
return [] unless ok
|
|
87
|
+
|
|
88
|
+
out.each_line.filter_map do |line|
|
|
89
|
+
name, head = line.chomp.split("\x00")
|
|
90
|
+
next unless name && !name.empty?
|
|
91
|
+
{ name: name, current: head == "*" }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Stage `files` (relative paths) and commit with `message`. Returns
|
|
96
|
+
# { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
|
|
97
|
+
# argv so paths/message cannot inject; no --no-verify, no amend.
|
|
98
|
+
def commit(dir, message:, files:)
|
|
99
|
+
msg = message.to_s.strip
|
|
100
|
+
paths = Array(files).map(&:to_s).reject(&:empty?)
|
|
101
|
+
return { ok: false, error: "commit message is required" } if msg.empty?
|
|
102
|
+
return { ok: false, error: "no files selected" } if paths.empty?
|
|
103
|
+
|
|
104
|
+
_out, add_err, add_ok = git(dir, "add", "--", *paths)
|
|
105
|
+
return { ok: false, error: "git add failed: #{add_err.strip}" } unless add_ok
|
|
106
|
+
|
|
107
|
+
_out, c_err, c_ok = git(dir, "commit", "-m", msg, "--", *paths)
|
|
108
|
+
return { ok: false, error: "git commit failed: #{c_err.strip}" } unless c_ok
|
|
109
|
+
|
|
110
|
+
head, _err, _ok = git(dir, "rev-parse", "--short", "HEAD")
|
|
111
|
+
{ ok: true, hash: head.strip }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|