samagotchi 0.65.0 → 0.67.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +50 -28
- data/docs/cli.md +14 -2
- data/docs/configuration.md +101 -16
- data/docs/context.md +39 -2
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +75 -30
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +36 -13
- data/docs/memory.md +1 -1
- data/docs/plugins.md +289 -25
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +16 -6
- data/docs/sub-agent.md +6 -1
- data/docs/testing.md +17 -3
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +23 -8
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/engine.rb +120 -107
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +138 -156
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +5 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +41 -15
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -9
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +52 -43
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +6 -4
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +17 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_model.js +2 -2
- data/lib/samagotchi/web/public/stream_handlers.js +13 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +13 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +23 -1
data/docs/testing.md
CHANGED
|
@@ -19,7 +19,7 @@ The web specs run in plain node. One that loads lit-html, itself or through a mo
|
|
|
19
19
|
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
20
20
|
`question_cards.js`, `stream_handlers.js`, the chips, `info_bar.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
|
|
21
21
|
`chains.js`, `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
|
|
22
|
-
never shipped;
|
|
22
|
+
never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
|
|
23
23
|
`htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
|
|
24
24
|
escaped (a `"` in text stays `"`) and an attribute with `&` and `"` escaped. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
|
|
25
25
|
`?selected`: read the option marked `selected` (`option[selected]`), not `select.value`; the browser follows the mark.
|
|
@@ -27,6 +27,11 @@ A select bound with `.value` instead reads right in both, but lit sets it before
|
|
|
27
27
|
`llm_context_chip.js`'s `renderForm` draws twice then. Assert a kept node with `assert.ok(a === b)`: a failing
|
|
28
28
|
`assert.equal` on two DOM nodes inspects the whole DOM (huge output, can stall the run).
|
|
29
29
|
|
|
30
|
+
`npm test` runs every file in one process (`--test-isolation=none`; CI and local runs use Node 24): the files share
|
|
31
|
+
`globalThis`, the shim's one happy-dom window and every module's state: a global a spec sets (stage_view's
|
|
32
|
+
`ResizeObserver` stand-in) is there for the files after it, so set only harmless stand-ins and put anything else
|
|
33
|
+
back (`t.mock` restores itself). One file: `node --test spec/web/public/NAME.test.js`.
|
|
34
|
+
|
|
30
35
|
Where a composer line goes is decided twice, in Ruby (`ComposerRoute`, both terminals) and in the web
|
|
31
36
|
(`composer.js#routeComposerLine`). `spec/fixtures/composer_routes.json` holds the cases both answer: rspec
|
|
32
37
|
(`spec/composer_route_spec.rb`) and `npm test` (`spec/web/public/composer.test.js`) run every row. A change to
|
|
@@ -44,8 +49,10 @@ script/affected-specs A..B # another range (A...B: from their merge-b
|
|
|
44
49
|
|
|
45
50
|
A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
|
|
46
51
|
files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
|
|
47
|
-
docs or source as text (its `RULES`: add one when a full run catches what the names missed), then the
|
|
48
|
-
|
|
52
|
+
docs or source as text (its `RULES`: add one when a full run catches what the names missed), then the other name
|
|
53
|
+
matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
|
|
54
|
+
`toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
|
|
55
|
+
`spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
|
|
49
56
|
The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" and a
|
|
50
57
|
hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
|
|
51
58
|
|
|
@@ -65,6 +72,13 @@ It runs `git worktree add ../samagotchi-NAME -b BRANCH main`, then `npm ci --pre
|
|
|
65
72
|
`npm test` and `npm run e2e` work there right away (Ruby gems and Playwright's browsers are shared: nothing else
|
|
66
73
|
to install). It refuses when the folder or the branch already exists.
|
|
67
74
|
|
|
75
|
+
## The desktop helper's build specs
|
|
76
|
+
|
|
77
|
+
`SAMAGOTCHI_MACOS_BUILD=1 bundle exec rspec spec/desktop` also compiles the macOS helper (`:macos_build`, needs the
|
|
78
|
+
Command Line Tools) and runs `ChiHelper` against a fake kitty and, when a `tmux` is on PATH, a real one (private
|
|
79
|
+
`-L` servers under a temp `TMUX_TMPDIR`, killed after). CI skips them, and `script/affected-specs` doesn't pick them
|
|
80
|
+
for a Swift change: run them by hand after editing `lib/samagotchi/desktop/macos/`.
|
|
81
|
+
|
|
68
82
|
## Integration specs
|
|
69
83
|
|
|
70
84
|
Examples tagged `:integration` (`spec/integration/`, the "with LLM access" part of `spec/hooks/integration_spec.rb`)
|
|
@@ -9,6 +9,7 @@ require_relative "../version"
|
|
|
9
9
|
require_relative "../llm/openai_chat"
|
|
10
10
|
require_relative "../llm/http"
|
|
11
11
|
require_relative "../llm/api_key"
|
|
12
|
+
require_relative "../host_keys"
|
|
12
13
|
require_relative "../redacted_inspect"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
@@ -229,10 +230,10 @@ module Samagotchi
|
|
|
229
230
|
end
|
|
230
231
|
|
|
231
232
|
# One attempt through LLM::HTTP (User-Agent, the key's Bearer header),
|
|
232
|
-
# any status returned as is. A key
|
|
233
|
-
# header: the server's 401 then says one is needed.
|
|
233
|
+
# any status returned as is. A key neither its variable nor the
|
|
234
|
+
# Keychain holds sends no header: the server's 401 then says one is needed.
|
|
234
235
|
def send_request(uri, request, key_env, read_timeout:)
|
|
235
|
-
key = key_env &&
|
|
236
|
+
key = key_env && HostKeys.fetch(key_env, env: @env) ? LLM::ApiKey.for(key_env, host: uri.host, env: @env) : nil
|
|
236
237
|
LLM::HTTP.new(label: uri.host, open_timeout: @timeout, read_timeout: read_timeout, api_key: key,
|
|
237
238
|
retry_policy: LLM::HTTP::RetryPolicy.none)
|
|
238
239
|
.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "probe" })
|
|
@@ -4,6 +4,7 @@ require "rbconfig"
|
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "model_profile"
|
|
6
6
|
require_relative "host_registry"
|
|
7
|
+
require_relative "host_keys"
|
|
7
8
|
require_relative "bootstrap/probe"
|
|
8
9
|
require_relative "bootstrap/config_writer"
|
|
9
10
|
require_relative "bootstrap/bundles"
|
|
@@ -87,7 +88,7 @@ module Samagotchi
|
|
|
87
88
|
|
|
88
89
|
key_env = options[:key_env]
|
|
89
90
|
if key_env && unset?(key_env)
|
|
90
|
-
fail!("#{key_env} is not set;
|
|
91
|
+
fail!("#{key_env} is not set; #{export_hint(key_env, "first")} (the API key; chi only writes its name)")
|
|
91
92
|
return @exit
|
|
92
93
|
end
|
|
93
94
|
|
|
@@ -187,7 +188,7 @@ module Samagotchi
|
|
|
187
188
|
key_env = @stdin.gets.to_s.strip
|
|
188
189
|
return fail!("no variable given") if key_env.empty?
|
|
189
190
|
return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
|
|
190
|
-
return fail!("#{key_env} is not set;
|
|
191
|
+
return fail!("#{key_env} is not set; #{export_hint(key_env, "and run chi bootstrap again")}") if unset?(key_env)
|
|
191
192
|
|
|
192
193
|
result = @probe.classify(result.candidate, key_env: key_env)
|
|
193
194
|
return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
|
|
@@ -433,7 +434,16 @@ module Samagotchi
|
|
|
433
434
|
@stdin.respond_to?(:tty?) && @stdin.tty?
|
|
434
435
|
end
|
|
435
436
|
|
|
436
|
-
|
|
437
|
+
# Neither the variable nor the Keychain (HostKeys) holds the key.
|
|
438
|
+
def unset?(name) = HostKeys.fetch(name, env: @env).nil?
|
|
439
|
+
|
|
440
|
+
# "export it first", or on macOS "export it or store it in the Keychain
|
|
441
|
+
# (security …) first".
|
|
442
|
+
def export_hint(name, tail)
|
|
443
|
+
return "export it #{tail}" unless HostKeys.available?
|
|
444
|
+
|
|
445
|
+
"export it or store it in the Keychain (#{HostKeys.store_command(name)}) #{tail}"
|
|
446
|
+
end
|
|
437
447
|
|
|
438
448
|
def fail!(message)
|
|
439
449
|
@stderr.puts("chi bootstrap: #{message}")
|
|
@@ -40,11 +40,6 @@ module Samagotchi
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
# @return [Integer, nil] the highest seq buffered so far.
|
|
44
|
-
def last_seq
|
|
45
|
-
@mutex.synchronize { @events.last&.[](:seq) }
|
|
46
|
-
end
|
|
47
|
-
|
|
48
43
|
# @return [Integer, nil] the lowest seq buffered so far.
|
|
49
44
|
def oldest_seq
|
|
50
45
|
@mutex.synchronize { @events.first&.[](:seq) }
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -374,7 +374,10 @@ module Samagotchi
|
|
|
374
374
|
|
|
375
375
|
Thread.new { handle_connection(client) }.tap do |t|
|
|
376
376
|
t.report_on_exception = false
|
|
377
|
-
|
|
377
|
+
# The ended connections' threads go as a new one comes, so a
|
|
378
|
+
# long-lived worker's list holds its open connections only. A new
|
|
379
|
+
# Array: #await_answers and #stop may be walking the old one.
|
|
380
|
+
@connection_threads = @connection_threads.select(&:alive?) << t
|
|
378
381
|
end
|
|
379
382
|
end
|
|
380
383
|
rescue StandardError => e
|
|
@@ -606,13 +609,13 @@ module Samagotchi
|
|
|
606
609
|
|
|
607
610
|
record, error = Tools::TaskRuntime.get_record(task_id)
|
|
608
611
|
return [{}, 404, { error: "task_not_found", task_id: task_id }] if error
|
|
609
|
-
return [{}, 409, { error: "not_running", status: record
|
|
612
|
+
return [{}, 409, { error: "not_running", status: record.status, task_id: task_id }] unless record.running?
|
|
610
613
|
|
|
611
614
|
Log.info(:bridge, "task_stop", task_id: task_id)
|
|
612
615
|
record, error = Tools::TaskRuntime.stop_task(task_id, by: "user")
|
|
613
616
|
return [{}, 500, { error: "bridge_error", detail: error }] if error
|
|
614
617
|
|
|
615
|
-
[{}, 200, { status: record
|
|
618
|
+
[{}, 200, { status: record.status, stop_reason: record.stop_reason, task_id: task_id }]
|
|
616
619
|
end
|
|
617
620
|
|
|
618
621
|
# The tasks this session started: its history's (created_ids_in) and
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: claude-peer
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.3.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-review context; /claude install-skill installs Claude's chi-peer skill, which pairs"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files:
|
|
8
8
|
skill_claude_peer.md:
|
|
9
|
-
sha256: sha256:
|
|
10
|
-
description: "Send a part of the work to the paired Claude Code session (claude_send): only when the user asks; validate, context or question; Claude's answer comes as the claude-review context"
|
|
9
|
+
sha256: sha256:9293740f3e1afa0fc2eff381f2c6406087598205df351abd22b4459a3bcb5d0e
|
|
10
|
+
description: "Send a part of the work to the paired Claude Code session (claude_send): only when the user asks, or to reply to its answer in a peer turn; validate, context or question; Claude's answer comes as the claude-review context"
|
|
11
11
|
plugin:
|
|
12
12
|
file: plugin.rb
|
|
13
|
-
sha256: sha256:
|
|
13
|
+
sha256: sha256:74960c46f4b98a453a6c6b16a53a4e9cb76752e6df1deecf55784fc7a0e616ce
|
|
14
14
|
scripts:
|
|
15
15
|
pair: sha256:033f1fc6a644950b57e71865253526a0a487bb4c9fd5a61bb89d031b65f40417
|
|
16
|
-
chi-peer-SKILL.md: sha256:
|
|
16
|
+
chi-peer-SKILL.md: sha256:753b609bca01877d46e6b1c3c5b74edba745433cbe33a86702d3ab75bc3f7107
|
|
17
17
|
# Two things need chi 0.63.0, and the bundle works without them on 0.62:
|
|
18
18
|
# ctx.context.list's summary: (the /claude card's last answer; without it
|
|
19
19
|
# the card shows no summary), and a pushed source's first wake: true push
|
|
20
20
|
# waking the session (on 0.62 Claude's first answer arrives as a note
|
|
21
21
|
# without waking; later ones wake).
|
|
22
|
-
|
|
22
|
+
# The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
|
|
23
|
+
requires_chi: ">= 0.67.0"
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
3
|
require "io/wait"
|
|
5
4
|
require "json"
|
|
6
|
-
require "open3"
|
|
7
5
|
require "socket"
|
|
8
|
-
require "
|
|
9
|
-
require "time"
|
|
6
|
+
require "samagotchi/plugin/peer"
|
|
10
7
|
|
|
11
8
|
# claude-peer (docs/plugins.md, The claude-peer bundle): a live link between
|
|
12
9
|
# this chi session and a Claude Code session the user works with. Claude
|
|
@@ -42,22 +39,28 @@ require "time"
|
|
|
42
39
|
# being the user's consent: the copy happens in-process, where no guardrail
|
|
43
40
|
# sees it. The skill is scripts/chi-peer-SKILL.md beside this file, with the
|
|
44
41
|
# installed scripts/pair path and the payload folder written in.
|
|
45
|
-
|
|
42
|
+
#
|
|
43
|
+
# In the web, selecting text offers "Confirm with Claude" (chi.annotation;
|
|
44
|
+
# skipped on a chi without it, 0.62–0.65): it fills the composer with
|
|
45
|
+
# `/claude validate` and the quote, for the user to add what to confirm.
|
|
46
|
+
# Such a text is titled by its first quoted line, not the quote's
|
|
47
|
+
# `From your thinking:` label.
|
|
48
|
+
#
|
|
49
|
+
# What the peer bundles share (the tool, the command, the cards, payloads)
|
|
50
|
+
# is Samagotchi::Plugin::Peer; this class has Claude's strings and the
|
|
51
|
+
# socket transport.
|
|
52
|
+
class Plugin < Samagotchi::Plugin::Peer
|
|
46
53
|
INBOX = "claude-inbox"
|
|
47
54
|
REVIEW = "claude-review"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
MAX_CHARS = 200_000
|
|
54
|
-
TITLE_CHARS = 80
|
|
55
|
-
SLUG_CHARS = 40
|
|
55
|
+
BUNDLE = "claude-peer"
|
|
56
|
+
NAME = "Claude"
|
|
57
|
+
APP = "Claude Code"
|
|
58
|
+
COMMAND = "/claude"
|
|
59
|
+
TOOL = "claude_send"
|
|
56
60
|
# The socket must take the line within this long.
|
|
57
61
|
WRITE_SECONDS = 5
|
|
58
62
|
# The Claude Code version the wire format was checked on.
|
|
59
63
|
CHECKED_ON = "Claude Code 2.1.296"
|
|
60
|
-
LAST_SEND = "last-send.json"
|
|
61
64
|
STATUS_CARD = "claude"
|
|
62
65
|
SKILL_CARD = "claude-skill"
|
|
63
66
|
USAGE = "usage: /claude — whether a Claude Code session is paired; /claude validate <text> | context <text> — send " \
|
|
@@ -66,17 +69,15 @@ class Plugin
|
|
|
66
69
|
# The shipped bundle keeps scripts/ beside plugin.rb; an installed one
|
|
67
70
|
# has plugin/plugin.rb and scripts/ beside plugin/.
|
|
68
71
|
SCRIPTS_DIRS = [File.join(__dir__, "scripts"), File.expand_path("../scripts", __dir__)].freeze
|
|
69
|
-
SKILL_FILE = "chi-peer-SKILL.md"
|
|
70
|
-
PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
|
|
71
|
-
PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
|
|
72
|
-
DIFF_LINES = 20
|
|
73
72
|
|
|
74
|
-
TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your
|
|
75
|
-
"Claude Code session paired with it (the claude-inbox
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
73
|
+
TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your " \
|
|
74
|
+
"last answer) to the Claude Code session paired with it (the claude-inbox " \
|
|
75
|
+
"context), for Claude to validate, to take as context, or to answer a " \
|
|
76
|
+
"question about. Only when the user asks for it, or to reply to its answer " \
|
|
77
|
+
"in a peer turn. Needs a paired Claude Code session: without one it fails, " \
|
|
78
|
+
"and the user pairs from Claude. Claude's answer comes back later as the " \
|
|
79
|
+
"claude-review context (a validate's wakes you): don't wait or poll. Never " \
|
|
80
|
+
"send secrets or the whole conversation."
|
|
80
81
|
TOOL_PARAMS = {
|
|
81
82
|
kind: { type: "string", enum: KINDS, required: true,
|
|
82
83
|
description: "validate: check this part and answer with a verdict; context: for Claude to know, no " \
|
|
@@ -88,268 +89,40 @@ class Plugin
|
|
|
88
89
|
"functions or diff range to look at. A long text goes to a file Claude reads" }
|
|
89
90
|
}.freeze
|
|
90
91
|
|
|
91
|
-
# A send that didn't happen; the message says why, for the model or the user.
|
|
92
|
-
class SendError < StandardError; end
|
|
93
|
-
|
|
94
|
-
def initialize(_settings = {})
|
|
95
|
-
@lock = Mutex.new
|
|
96
|
-
# The running turn's history before it (nil between turns), and the
|
|
97
|
-
# last completed answer this process saw.
|
|
98
|
-
@before_turn = nil
|
|
99
|
-
@last_answer = nil
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def register(chi)
|
|
103
|
-
chi.tool "claude_send", TOOL_DESCRIPTION, params: TOOL_PARAMS, label: "sent to Claude",
|
|
104
|
-
preview: ->(args) { "#{args["kind"]}: #{args["title"]}" } do |args, ctx|
|
|
105
|
-
sent = deliver(ctx, kind: args["kind"].to_s, title: args["title"].to_s, text: args["text"].to_s)
|
|
106
|
-
"#{sent}. #{follow_up(args["kind"].to_s)}"
|
|
107
|
-
rescue SendError => e
|
|
108
|
-
"Error: #{e.message}"
|
|
109
|
-
end
|
|
110
|
-
chi.command "/claude", "the paired Claude Code session: status; validate|context <text>, last: send to it; " \
|
|
111
|
-
"install-skill: install Claude's chi-peer skill", anytime: true do |args, ctx|
|
|
112
|
-
claude(args.to_s.strip, ctx)
|
|
113
|
-
end
|
|
114
|
-
chi.on(:before_turn) do |event|
|
|
115
|
-
@lock.synchronize { @before_turn = Array(event[:messages]) }
|
|
116
|
-
end
|
|
117
|
-
chi.on(:after_turn) do |event|
|
|
118
|
-
answer = completed_answer(event[:messages]) if event[:status].to_s == "completed"
|
|
119
|
-
@lock.synchronize do
|
|
120
|
-
@before_turn = nil
|
|
121
|
-
@last_answer = answer if answer
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Sends +text+ to the paired Claude: the header, then the text inline or
|
|
127
|
-
# a payload file's path. Records it as the last send.
|
|
128
|
-
# @return [String] what was sent ("sent to Claude: validate · title, inline")
|
|
129
|
-
# @raise [SendError]
|
|
130
|
-
def deliver(ctx, kind:, title:, text:)
|
|
131
|
-
raise SendError, "kind must be one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
|
|
132
|
-
raise SendError, "nothing to send: the text is empty" if text.strip.empty?
|
|
133
|
-
if text.length > MAX_CHARS
|
|
134
|
-
raise SendError, "the text is #{text.length} chars, over #{MAX_CHARS}: send a part, not a session"
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
id = ctx.session_id or raise SendError, "this session has no id yet: send after its first turn"
|
|
138
|
-
socket = socket_path(ctx)
|
|
139
|
-
title = one_line(title.strip.empty? ? text.strip.lines.first : title)
|
|
140
|
-
body, where = if text.length <= INLINE_CHARS
|
|
141
|
-
[text, "inline"]
|
|
142
|
-
else
|
|
143
|
-
path = write_payload(ctx, id, title, text)
|
|
144
|
-
["payload: #{path} (#{text.lines.size} lines, #{text.length} chars)", path]
|
|
145
|
-
end
|
|
146
|
-
post(socket, line("chi #{id[0, 8]} · #{kind} · #{title}\n#{body}"))
|
|
147
|
-
record(ctx, id, kind: kind, title: title, chars: text.length, payload: where == "inline" ? nil : where)
|
|
148
|
-
"sent to Claude: #{kind} · #{title}#{" (the text in #{where})" unless where == "inline"}"
|
|
149
|
-
end
|
|
150
|
-
|
|
151
92
|
# The one JSON line Claude Code's inbox socket takes.
|
|
152
93
|
def line(content) = "#{JSON.generate(type: "user", message: { role: "user", content: content })}\n"
|
|
153
94
|
|
|
154
95
|
private
|
|
155
96
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
rest = args.sub(/\A\S+\s*/, "")
|
|
160
|
-
case verb
|
|
161
|
-
when nil then status(ctx)
|
|
162
|
-
when "validate", "context" then rest.strip.empty? ? USAGE : send_text(ctx, verb, rest)
|
|
163
|
-
when "last" then rest.empty? ? send_last(ctx) : USAGE
|
|
164
|
-
when "install-skill" then install_skill(ctx, yes: rest == "--yes")
|
|
165
|
-
else USAGE
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def send_text(ctx, kind, text)
|
|
170
|
-
"#{deliver(ctx, kind: kind, title: text.strip.lines.first.to_s, text: text)}; Claude's answer comes as the " \
|
|
171
|
-
"claude-review context"
|
|
172
|
-
rescue SendError => e
|
|
173
|
-
"/claude: #{e.message}"
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def send_last(ctx)
|
|
177
|
-
answer, before = @lock.synchronize { [@last_answer, @before_turn] }
|
|
178
|
-
answer ||= completed_answer(before || ctx.messages)
|
|
179
|
-
return "/claude last: no completed answer to send yet (the last turn has none); /claude context <text> sends a text" unless answer
|
|
180
|
-
|
|
181
|
-
deliver(ctx, kind: "context", title: "chi's last answer: #{answer.strip.lines.first}", text: answer)
|
|
182
|
-
rescue SendError => e
|
|
183
|
-
"/claude: #{e.message}"
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# The answer a completed turn left: the history's last message when it
|
|
187
|
-
# is the model's (a cancelled, failed or empty turn ends in a turn note),
|
|
188
|
-
# after any notes that joined between turns; its thinking left out.
|
|
189
|
-
def completed_answer(messages)
|
|
190
|
-
last = Array(messages).reverse.find { |message| !(role(message) == "system" && kind(message) != "turn_note") }
|
|
191
|
-
return nil unless last && role(last) == "model" && !field(last, :interrupted) && !field(last, :tool_calls)
|
|
192
|
-
|
|
193
|
-
text = field(last, :content)
|
|
194
|
-
text = text.filter_map { |part| field(part, :text) if part.is_a?(Hash) }.join("\n") if text.is_a?(Array)
|
|
195
|
-
text = text.to_s.gsub(%r{<think>.*?</think>}m, "").strip
|
|
196
|
-
text.empty? ? nil : text
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
# The session's attached sources; none when they can't be read (chi 0.62
|
|
200
|
-
# raises for a new session not saved yet; a corrupt session file).
|
|
201
|
-
def sources(ctx)
|
|
202
|
-
ctx.context.list
|
|
203
|
-
rescue ArgumentError, SystemCallError
|
|
204
|
-
[]
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def role(message) = field(message, :role).to_s
|
|
208
|
-
def kind(message) = field(message, :kind).to_s
|
|
209
|
-
def field(hash, key) = hash.is_a?(Hash) ? hash[key] || hash[key.to_s] : nil
|
|
210
|
-
|
|
211
|
-
# The status card; nil, so the command adds no text.
|
|
212
|
-
def status(ctx)
|
|
213
|
-
id = ctx.session_id
|
|
214
|
-
sources = id ? sources(ctx) : []
|
|
215
|
-
inbox, review = [INBOX, REVIEW].map { |name| sources.find { |source| source[:name] == name } }
|
|
216
|
-
socket = inbox && inbox[:hint].to_s.strip
|
|
217
|
-
lines = if socket.nil? || socket.empty?
|
|
218
|
-
["**not paired.** In Claude Code, install the chi-peer skill (Install skill below), then tell Claude " \
|
|
219
|
-
"\"pair with chi #{id.to_s[0, 8]}\"."]
|
|
220
|
-
else
|
|
221
|
-
there = File.socket?(socket) ? "there" : "**missing**: that Claude session ended or restarted, pair again"
|
|
222
|
-
["**paired** with a Claude Code session", "socket: `#{socket}` (#{there})"]
|
|
223
|
-
end
|
|
224
|
-
lines << "last send: #{last_send_line(ctx, id)}" if id && last_send(ctx, id)
|
|
225
|
-
lines << "last answer (claude-review): #{review_line(review)}" if review
|
|
226
|
-
lines << "The inbox's format is undocumented: checked on #{CHECKED_ON}. A Claude that bypasses permissions " \
|
|
227
|
-
"holds peer messages for approval."
|
|
228
|
-
ctx.card(id: STATUS_CARD, title: "claude-peer: #{lines.first.start_with?("**paired") ? "paired" : "not paired"}",
|
|
229
|
-
body: lines.join("\n\n"), actions: [{ label: "Refresh", command: "/claude" },
|
|
230
|
-
{ label: "Install skill", command: "/claude install-skill" }])
|
|
231
|
-
nil
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def last_send(ctx, id)
|
|
235
|
-
path = File.join(ctx.data_dir, "sessions", id, LAST_SEND)
|
|
236
|
-
File.file?(path) ? JSON.parse(File.read(path)) : nil
|
|
237
|
-
rescue JSON::ParserError, SystemCallError
|
|
238
|
-
nil
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def last_send_line(ctx, id)
|
|
242
|
-
sent = last_send(ctx, id)
|
|
243
|
-
where = sent["payload"] ? "in `#{sent["payload"]}`" : "inline"
|
|
244
|
-
"#{ago(sent["at"])} · #{sent["kind"]} · #{sent["title"]} (#{sent["chars"]} chars, #{where})"
|
|
97
|
+
# The paired Claude's inbox socket: the claude-inbox context's hint.
|
|
98
|
+
def check_hint!(hint)
|
|
99
|
+
raise SendError, "claude-inbox's hint isn't a socket path (#{hint}): pair again from Claude" unless hint.start_with?("/")
|
|
245
100
|
end
|
|
246
101
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
[review[:summary], ago(review[:fetched_at])].compact.join(" · ")
|
|
102
|
+
def paired_lines(socket)
|
|
103
|
+
there = File.socket?(socket) ? "there" : "**missing**: that Claude session ended or restarted, pair again"
|
|
104
|
+
["**paired** with a Claude Code session", "socket: `#{socket}` (#{there})"]
|
|
252
105
|
end
|
|
253
106
|
|
|
254
|
-
def
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
elsif seconds < 3600 then "#{seconds / 60} min ago"
|
|
258
|
-
elsif seconds < 86_400 then "#{seconds / 3600} h ago"
|
|
259
|
-
else Time.iso8601(stamp).localtime.strftime("%Y-%m-%d %H:%M")
|
|
260
|
-
end
|
|
261
|
-
rescue ArgumentError
|
|
262
|
-
stamp.to_s
|
|
107
|
+
def transport_line
|
|
108
|
+
"The inbox's format is undocumented: checked on #{CHECKED_ON}. A Claude that bypasses permissions holds peer " \
|
|
109
|
+
"messages for approval."
|
|
263
110
|
end
|
|
264
111
|
|
|
265
|
-
|
|
266
|
-
def install_skill(ctx, yes:)
|
|
267
|
-
source = SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
|
|
268
|
-
return "/claude install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install claude-peer --force)" unless source
|
|
269
|
-
|
|
270
|
-
text = File.read(source).gsub(PAIR_PLACEHOLDER, File.join(File.dirname(source), "pair"))
|
|
271
|
-
.gsub(PAYLOADS_PLACEHOLDER, tilde(ctx.data_dir))
|
|
272
|
-
target = File.join(claude_dir, "skills", "chi-peer", "SKILL.md")
|
|
273
|
-
yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
def skill_card(ctx, target, text)
|
|
277
|
-
current = File.file?(target) ? File.read(target) : nil
|
|
278
|
-
state = if current.nil? then "Not installed yet."
|
|
279
|
-
elsif current == text then "Installed and up to date."
|
|
280
|
-
else "Installed, and different from this bundle's (Replace overwrites it):\n\n```diff\n#{diff(target, text)}\n```"
|
|
281
|
-
end
|
|
112
|
+
def setup_paragraph(ctx)
|
|
282
113
|
rule = "Read(#{tilde(ctx.data_dir)}/**)"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
"So Claude isn't asked before reading a long part's file, add `#{rule}` to Claude Code's permissions " \
|
|
286
|
-
"(`permissions.allow` in its settings.json); chi doesn't change Claude's settings."]
|
|
287
|
-
actions = if current == text
|
|
288
|
-
[]
|
|
289
|
-
else
|
|
290
|
-
[{ label: current ? "Replace" : "Install", command: "/claude install-skill --yes" }]
|
|
291
|
-
end
|
|
292
|
-
ctx.card(id: SKILL_CARD, title: "Claude's chi-peer skill", body: body.join("\n\n"), actions: actions)
|
|
293
|
-
nil
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
def write_skill(ctx, target, text)
|
|
297
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
298
|
-
File.write(target, text)
|
|
299
|
-
ctx.card(id: SKILL_CARD, title: "Claude's chi-peer skill",
|
|
300
|
-
body: "Installed `#{target}`. In Claude Code, say \"pair with chi #{ctx.session_id.to_s[0, 8]}\".")
|
|
301
|
-
nil
|
|
302
|
-
rescue SystemCallError => e
|
|
303
|
-
"/claude install-skill: couldn't write #{target}: #{e.message}"
|
|
114
|
+
"So Claude isn't asked before reading a long part's file, add `#{rule}` to Claude Code's permissions " \
|
|
115
|
+
"(`permissions.allow` in its settings.json); chi doesn't change Claude's settings."
|
|
304
116
|
end
|
|
305
117
|
|
|
306
118
|
# Claude Code's config folder: CLAUDE_CONFIG_DIR, else ~/.claude.
|
|
307
|
-
def
|
|
119
|
+
def peer_dir
|
|
308
120
|
dir = ENV.fetch("CLAUDE_CONFIG_DIR", "").strip
|
|
309
121
|
dir.empty? ? File.join(Dir.home, ".claude") : File.expand_path(dir)
|
|
310
122
|
end
|
|
311
123
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
Tempfile.create(["chi-peer", ".md"]) do |file|
|
|
315
|
-
file.write(text)
|
|
316
|
-
file.flush
|
|
317
|
-
out, = Open3.capture2("diff", "-u", "--label", "installed", "--label", "this bundle's", target, file.path)
|
|
318
|
-
lines = out.lines
|
|
319
|
-
lines = lines.first(DIFF_LINES) + ["… #{lines.size - DIFF_LINES} more lines\n"] if lines.size > DIFF_LINES
|
|
320
|
-
lines.join.chomp
|
|
321
|
-
end
|
|
322
|
-
rescue SystemCallError
|
|
323
|
-
"(diff isn't there to show how)"
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
def tilde(path)
|
|
327
|
-
home = Dir.home
|
|
328
|
-
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
def follow_up(kind)
|
|
332
|
-
case kind
|
|
333
|
-
when "context" then "Claude takes it in; it answers only if something in it is wrong."
|
|
334
|
-
else "Claude's answer comes as an update of the claude-review context; don't wait or poll."
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
# The paired Claude's inbox socket: the claude-inbox context's hint.
|
|
339
|
-
def socket_path(ctx)
|
|
340
|
-
inbox = sources(ctx).find { |source| source[:name] == INBOX }
|
|
341
|
-
hint = inbox && inbox[:hint].to_s.strip
|
|
342
|
-
if hint.nil? || hint.empty?
|
|
343
|
-
raise SendError, "no claude-inbox: pair from Claude (install its skill with /claude install-skill, then " \
|
|
344
|
-
"tell Claude \"pair with chi #{ctx.session_id.to_s[0, 8]}\")"
|
|
345
|
-
end
|
|
346
|
-
raise SendError, "claude-inbox's hint isn't a socket path (#{hint}): pair again from Claude" unless hint.start_with?("/")
|
|
347
|
-
|
|
348
|
-
hint
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
def post(socket, data)
|
|
352
|
-
UNIXSocket.open(socket) { |conn| write_all(conn, data) }
|
|
124
|
+
def post(socket, text)
|
|
125
|
+
UNIXSocket.open(socket) { |conn| write_all(conn, line(text)) }
|
|
353
126
|
rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::ENOTSOCK
|
|
354
127
|
raise SendError, "that Claude session is gone (no inbox at #{socket}): pair again from Claude"
|
|
355
128
|
rescue SystemCallError, IOError => e
|
|
@@ -368,39 +141,4 @@ class Plugin
|
|
|
368
141
|
data = data.byteslice(written..)
|
|
369
142
|
end
|
|
370
143
|
end
|
|
371
|
-
|
|
372
|
-
# sessions/<id>/<HHMMSS>-<slug>.md under the bundle's data dir, readable
|
|
373
|
-
# by its owner only.
|
|
374
|
-
def write_payload(ctx, id, title, text)
|
|
375
|
-
dir = session_dir(ctx, id)
|
|
376
|
-
base = "#{Time.now.strftime("%H%M%S")}-#{slug(title)}"
|
|
377
|
-
path = File.join(dir, "#{base}.md")
|
|
378
|
-
n = 1
|
|
379
|
-
path = File.join(dir, "#{base}-#{n += 1}.md") while File.exist?(path)
|
|
380
|
-
File.open(path, "w", 0o600) { |file| file.write(text) }
|
|
381
|
-
path
|
|
382
|
-
rescue SystemCallError => e
|
|
383
|
-
raise SendError, "couldn't write the payload file: #{e.message}"
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
def session_dir(ctx, id)
|
|
387
|
-
File.join(ctx.data_dir, "sessions", id).tap { |dir| FileUtils.mkdir_p(dir) }
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
def record(ctx, id, **send)
|
|
391
|
-
entry = { at: Time.now.utc.iso8601, **send }
|
|
392
|
-
@lock.synchronize { File.write(File.join(session_dir(ctx, id), LAST_SEND), JSON.generate(entry)) }
|
|
393
|
-
rescue SystemCallError => e
|
|
394
|
-
ctx.log.info(:last_send_not_saved, error: e.message)
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
def slug(title)
|
|
398
|
-
slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")[0, SLUG_CHARS].delete_suffix("-")
|
|
399
|
-
slug.empty? ? "part" : slug
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
def one_line(text)
|
|
403
|
-
line = text.to_s.gsub(/\s+/, " ").strip
|
|
404
|
-
line.length > TITLE_CHARS ? "#{line[0, TITLE_CHARS - 1]}…" : line
|
|
405
|
-
end
|
|
406
144
|
end
|
|
@@ -59,6 +59,9 @@ permissions.
|
|
|
59
59
|
`file:line` where you can), what is missing, one improvement if it matters,
|
|
60
60
|
and a last line `VERDICT: OK` / `VERDICT: CHANGES` / `VERDICT: UNSURE (why)`.
|
|
61
61
|
Verify claims against the code; say what you did not check.
|
|
62
|
+
- A **validate** whose title you answered before is a follow-up round: chi
|
|
63
|
+
fixed what you flagged, or disagrees. Check it again and answer the same
|
|
64
|
+
way; say `VERDICT: OK` once you are satisfied (chi stops the round on it).
|
|
62
65
|
- **context**: take it in for your own work. No reply, unless something in it
|
|
63
66
|
contradicts what you know: then reply with that.
|
|
64
67
|
- **question**: answer it briefly.
|
|
@@ -80,9 +83,11 @@ EOF
|
|
|
80
83
|
- `wake` for a validate or a question (chi asked and is waiting); no `wake`
|
|
81
84
|
for a reply to context.
|
|
82
85
|
- Each answer replaces the last (chi keeps the latest one), so start the
|
|
83
|
-
summary with the title you are answering
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
summary with the title you are answering, and end it with your verdict
|
|
87
|
+
(`VERDICT OK`, `VERDICT CHANGES` or `VERDICT UNSURE`): chi doesn't wake for
|
|
88
|
+
an OK.
|
|
89
|
+
- Never answer a chi message with `chi send`: it counts as the user typing,
|
|
90
|
+
outside the round's limit. Use it only when the user asks you to.
|
|
86
91
|
|
|
87
92
|
## Don't loop
|
|
88
93
|
|