samagotchi 0.65.0 → 0.66.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 +48 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/cli.md +11 -1
- data/docs/configuration.md +51 -7
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +4 -1
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +10 -0
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -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/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +8 -1
- data/lib/samagotchi/engine.rb +112 -4
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- 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_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -0
- 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 +21 -7
- data/lib/samagotchi/web/public/commands.js +4 -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/session_state.js +3 -0
- data/lib/samagotchi/web/public/stream_handlers.js +13 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/worker.rb +2 -0
- metadata +10 -1
data/docs/sub-agent.md
CHANGED
|
@@ -17,7 +17,9 @@ The other direction, chi sending a part of its work to a Claude Code session
|
|
|
17
17
|
you already work with (to validate, as context, or with a question), is the
|
|
18
18
|
[claude-peer bundle](plugins.md#the-claude-peer-bundle): Claude pairs with the
|
|
19
19
|
chi session, chi's `claude_send` tool or `/claude` writes to Claude's inbox,
|
|
20
|
-
and Claude's answer comes back as attached context.
|
|
20
|
+
and Claude's answer comes back as attached context. The
|
|
21
|
+
[codex-peer bundle](plugins.md#the-codex-peer-bundle) does the same with a
|
|
22
|
+
Codex CLI session (`codex_send`, `/codex`, through `codex queue`).
|
|
21
23
|
|
|
22
24
|
## The commands
|
|
23
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: claude-peer
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.2.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
|
|
@@ -10,7 +10,7 @@ files:
|
|
|
10
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"
|
|
11
11
|
plugin:
|
|
12
12
|
file: plugin.rb
|
|
13
|
-
sha256: sha256:
|
|
13
|
+
sha256: sha256:ee44f119d6f8c6cb25917a0f3fb2cfd1fbca424d537ee5fa7043594a9eb04098
|
|
14
14
|
scripts:
|
|
15
15
|
pair: sha256:033f1fc6a644950b57e71865253526a0a487bb4c9fd5a61bb89d031b65f40417
|
|
16
16
|
chi-peer-SKILL.md: sha256:f35f726b1a86091ff69d289e8dd58794ac6f44879a63630fcc88d69867c56e20
|
|
@@ -42,6 +42,12 @@ require "time"
|
|
|
42
42
|
# being the user's consent: the copy happens in-process, where no guardrail
|
|
43
43
|
# sees it. The skill is scripts/chi-peer-SKILL.md beside this file, with the
|
|
44
44
|
# installed scripts/pair path and the payload folder written in.
|
|
45
|
+
#
|
|
46
|
+
# In the web, selecting text offers "Confirm with Claude" (chi.annotation;
|
|
47
|
+
# skipped on a chi without it, 0.62–0.65): it fills the composer with
|
|
48
|
+
# `/claude validate` and the quote, for the user to add what to confirm.
|
|
49
|
+
# Such a text is titled by its first quoted line, not the quote's
|
|
50
|
+
# `From your thinking:` label.
|
|
45
51
|
class Plugin
|
|
46
52
|
INBOX = "claude-inbox"
|
|
47
53
|
REVIEW = "claude-review"
|
|
@@ -70,6 +76,8 @@ class Plugin
|
|
|
70
76
|
PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
|
|
71
77
|
PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
|
|
72
78
|
DIFF_LINES = 20
|
|
79
|
+
# The label line a web quote starts with (annotations.js sourceLabel).
|
|
80
|
+
QUOTE_LABEL = /\AFrom (?:your thinking|your earlier step|the .+ call|my earlier message):\z/
|
|
73
81
|
|
|
74
82
|
TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your last answer) to the " \
|
|
75
83
|
"Claude Code session paired with it (the claude-inbox context), for Claude to validate, to take " \
|
|
@@ -111,6 +119,8 @@ class Plugin
|
|
|
111
119
|
"install-skill: install Claude's chi-peer skill", anytime: true do |args, ctx|
|
|
112
120
|
claude(args.to_s.strip, ctx)
|
|
113
121
|
end
|
|
122
|
+
# A chi without chi.annotation (0.62–0.65) loads the bundle without the pill.
|
|
123
|
+
chi.annotation "Confirm with Claude", command: "/claude validate" if chi.respond_to?(:annotation)
|
|
114
124
|
chi.on(:before_turn) do |event|
|
|
115
125
|
@lock.synchronize { @before_turn = Array(event[:messages]) }
|
|
116
126
|
end
|
|
@@ -167,12 +177,19 @@ class Plugin
|
|
|
167
177
|
end
|
|
168
178
|
|
|
169
179
|
def send_text(ctx, kind, text)
|
|
170
|
-
"#{deliver(ctx, kind: kind, title: text
|
|
180
|
+
"#{deliver(ctx, kind: kind, title: text_title(text), text: text)}; Claude's answer comes as the " \
|
|
171
181
|
"claude-review context"
|
|
172
182
|
rescue SendError => e
|
|
173
183
|
"/claude: #{e.message}"
|
|
174
184
|
end
|
|
175
185
|
|
|
186
|
+
# A typed text's title: its first line, past a web quote's label line
|
|
187
|
+
# (`From your thinking:`) and the `> ` of a quoted line.
|
|
188
|
+
def text_title(text)
|
|
189
|
+
lines = text.strip.lines.map(&:strip).reject { |line| line.match?(QUOTE_LABEL) }
|
|
190
|
+
lines.map { |line| line.sub(/\A>\s*/, "") }.find { |line| !line.empty? }.to_s
|
|
191
|
+
end
|
|
192
|
+
|
|
176
193
|
def send_last(ctx)
|
|
177
194
|
answer, before = @lock.synchronize { [@last_answer, @before_turn] }
|
|
178
195
|
answer ||= completed_answer(before || ctx.messages)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codex-peer
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
scope: system
|
|
5
|
+
description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-review context; /codex install-skill installs Codex's chi-peer skill, which pairs"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
files:
|
|
8
|
+
skill_codex_peer.md:
|
|
9
|
+
sha256: sha256:b17b97830d7895ca6bf393fe040d71a2eacc2e0b68fa9a23ba35d44f39132493
|
|
10
|
+
description: "Send a part of the work to the paired Codex CLI session (codex_send): only when the user asks; validate, context or question; Codex's answer comes as the codex-review context"
|
|
11
|
+
plugin:
|
|
12
|
+
file: plugin.rb
|
|
13
|
+
sha256: sha256:365382abe8f9f72e82eb965b762969e64c3774e3279d1e9cdc63a3f7286eb76f
|
|
14
|
+
scripts:
|
|
15
|
+
pair: sha256:90cc286587260a4221c6ca9621cf5f34dd2ac42dc1e6393b69ba3e974806e695
|
|
16
|
+
chi-peer-SKILL.md: sha256:886b6c430e9678f7f8bd991bdb374b6146ca2cbb0f42e36865ab14f5a21fa6e0
|
|
17
|
+
# Codex's answer is a pushed source's first wake: true push, which wakes the
|
|
18
|
+
# session from chi 0.63.0 (before, the first answer arrives as a note
|
|
19
|
+
# without waking); ctx.context.list's summary: (the /codex card's last
|
|
20
|
+
# answer) is 0.63.0 too.
|
|
21
|
+
requires_chi: ">= 0.63.0"
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
# codex-peer (docs/plugins.md, The codex-peer bundle): a live link between
|
|
10
|
+
# this chi session and an OpenAI Codex CLI session the user works with, the
|
|
11
|
+
# claude-peer bundle with the transport swapped. Codex pairs (its chi-peer
|
|
12
|
+
# skill, scripts/pair): it attaches the `codex-inbox` context, whose hint is
|
|
13
|
+
# its thread id (CODEX_THREAD_ID), and `codex-review`, where its answers
|
|
14
|
+
# come back as pushes that wake chi.
|
|
15
|
+
#
|
|
16
|
+
# The codex_send tool sends a part of the work (a plan step, a diff, a
|
|
17
|
+
# finding) with `codex queue --thread <uuid> --message <text>`, which wakes
|
|
18
|
+
# an idle Codex TUI: the text runs there as a new user turn (checked on
|
|
19
|
+
# Codex 0.162.1). Plain text, no wire format. The message starts with a
|
|
20
|
+
# header, `chi <id> · <kind> · <title>`; a short part follows inline, a
|
|
21
|
+
# long one goes to a file under the bundle's data dir (sessions/<id>/,
|
|
22
|
+
# removed with the session) and the message names it (`payload: <path>`).
|
|
23
|
+
# `codex` is CODEX, else codex on the PATH; a queue that takes over
|
|
24
|
+
# QUEUE_SECONDS is killed (its process group), so a send never hangs.
|
|
25
|
+
#
|
|
26
|
+
# The tool is declared whether or not Codex is paired, so pairing never
|
|
27
|
+
# changes the tools (no prompt rebuild). Without a paired Codex it fails
|
|
28
|
+
# with how to pair.
|
|
29
|
+
#
|
|
30
|
+
# /codex (anytime: it reads files, writes one and runs `codex queue`, up to
|
|
31
|
+
# QUEUE_SECONDS) lets the user send without the model: `/codex` alone is a
|
|
32
|
+
# status card (paired or not, the thread, the last send, the last
|
|
33
|
+
# codex-review answer); `/codex validate|context <text>` sends the text as
|
|
34
|
+
# it is, titled by its first line; `/codex last` sends the model's last
|
|
35
|
+
# completed answer as context (mid-turn, a worker's ctx.messages holds the
|
|
36
|
+
# running turn's partial text, so the before_turn and after_turn hooks keep
|
|
37
|
+
# what was completed); `/codex install-skill` shows a card with Codex's
|
|
38
|
+
# chi-peer skill's target ($CODEX_HOME, else ~/.codex) and an Install
|
|
39
|
+
# action (`--yes`), the card being the user's consent: the copy happens
|
|
40
|
+
# in-process, where no guardrail sees it. The skill is
|
|
41
|
+
# scripts/chi-peer-SKILL.md beside this file, with the installed
|
|
42
|
+
# scripts/pair path, the payload folder and chi's context folder written
|
|
43
|
+
# in. The card shows the writable_roots line Codex's config.toml needs for
|
|
44
|
+
# pairing and replies (its default sandbox can't write chi's state); chi
|
|
45
|
+
# never edits Codex's config.
|
|
46
|
+
#
|
|
47
|
+
# In the web, selecting text offers "Confirm with Codex" (chi.annotation;
|
|
48
|
+
# skipped on a chi without it, 0.63–0.65): it fills the composer with
|
|
49
|
+
# `/codex validate` and the quote, for the user to add what to confirm.
|
|
50
|
+
# Such a text is titled by its first quoted line, not the quote's
|
|
51
|
+
# `From your thinking:` label.
|
|
52
|
+
class Plugin
|
|
53
|
+
INBOX = "codex-inbox"
|
|
54
|
+
REVIEW = "codex-review"
|
|
55
|
+
KINDS = %w[validate context question].freeze
|
|
56
|
+
# A part up to this long goes inline (and keeps codex queue's argv small).
|
|
57
|
+
INLINE_CHARS = 4_000
|
|
58
|
+
# A part longer than this is refused (a send is a part, not a session).
|
|
59
|
+
MAX_CHARS = 200_000
|
|
60
|
+
TITLE_CHARS = 80
|
|
61
|
+
SLUG_CHARS = 40
|
|
62
|
+
# codex queue must be done within this long, or it is killed.
|
|
63
|
+
QUEUE_SECONDS = 10
|
|
64
|
+
# The Codex CLI version codex queue was checked on.
|
|
65
|
+
CHECKED_ON = "Codex 0.162.1"
|
|
66
|
+
THREAD_ID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
67
|
+
LAST_SEND = "last-send.json"
|
|
68
|
+
STATUS_CARD = "codex"
|
|
69
|
+
SKILL_CARD = "codex-skill"
|
|
70
|
+
USAGE = "usage: /codex — whether a Codex session is paired; /codex validate <text> | context <text> — send the " \
|
|
71
|
+
"text to it; /codex last — send the last answer as context; /codex install-skill — install Codex's " \
|
|
72
|
+
"chi-peer skill, which pairs"
|
|
73
|
+
# The shipped bundle keeps scripts/ beside plugin.rb; an installed one
|
|
74
|
+
# has plugin/plugin.rb and scripts/ beside plugin/.
|
|
75
|
+
SCRIPTS_DIRS = [File.join(__dir__, "scripts"), File.expand_path("../scripts", __dir__)].freeze
|
|
76
|
+
SKILL_FILE = "chi-peer-SKILL.md"
|
|
77
|
+
PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
|
|
78
|
+
PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
|
|
79
|
+
CONTEXT_PLACEHOLDER = "__CHI_PEER_CONTEXT__"
|
|
80
|
+
DIFF_LINES = 20
|
|
81
|
+
# The label line a web quote starts with (annotations.js sourceLabel).
|
|
82
|
+
QUOTE_LABEL = /\AFrom (?:your thinking|your earlier step|the .+ call|my earlier message):\z/
|
|
83
|
+
|
|
84
|
+
TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your last answer) to the " \
|
|
85
|
+
"Codex CLI session paired with it (the codex-inbox context), for Codex to validate, to take " \
|
|
86
|
+
"as context, or to answer a question about. Only when the user asks for it. Needs a paired " \
|
|
87
|
+
"Codex session: without one it fails, and the user pairs from Codex. Codex's answer comes " \
|
|
88
|
+
"back later as the codex-review context (a validate's wakes you): don't wait or poll. Never " \
|
|
89
|
+
"send secrets or the whole conversation."
|
|
90
|
+
TOOL_PARAMS = {
|
|
91
|
+
kind: { type: "string", enum: KINDS, required: true,
|
|
92
|
+
description: "validate: check this part and answer with a verdict; context: for Codex to know, no " \
|
|
93
|
+
"answer expected; question: answer the question in text" },
|
|
94
|
+
title: { type: "string", required: true,
|
|
95
|
+
description: "A short one-line title for the part; Codex's answer names it" },
|
|
96
|
+
text: { type: "string", required: true,
|
|
97
|
+
description: "The part itself and what to check or ask, as Codex should read it; name the files, " \
|
|
98
|
+
"functions or diff range to look at. A long text goes to a file Codex reads" }
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
101
|
+
# A send that didn't happen; the message says why, for the model or the user.
|
|
102
|
+
class SendError < StandardError; end
|
|
103
|
+
|
|
104
|
+
def initialize(_settings = {})
|
|
105
|
+
@lock = Mutex.new
|
|
106
|
+
# The running turn's history before it (nil between turns), and the
|
|
107
|
+
# last completed answer this process saw.
|
|
108
|
+
@before_turn = nil
|
|
109
|
+
@last_answer = nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def register(chi)
|
|
113
|
+
chi.tool "codex_send", TOOL_DESCRIPTION, params: TOOL_PARAMS, label: "sent to Codex",
|
|
114
|
+
preview: ->(args) { "#{args["kind"]}: #{args["title"]}" } do |args, ctx|
|
|
115
|
+
sent = deliver(ctx, kind: args["kind"].to_s, title: args["title"].to_s, text: args["text"].to_s)
|
|
116
|
+
"#{sent}. #{follow_up(args["kind"].to_s)}"
|
|
117
|
+
rescue SendError => e
|
|
118
|
+
"Error: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
chi.command "/codex", "the paired Codex session: status; validate|context <text>, last: send to it; " \
|
|
121
|
+
"install-skill: install Codex's chi-peer skill", anytime: true do |args, ctx|
|
|
122
|
+
codex(args.to_s.strip, ctx)
|
|
123
|
+
end
|
|
124
|
+
# A chi without chi.annotation (0.63–0.65) loads the bundle without the pill.
|
|
125
|
+
chi.annotation "Confirm with Codex", command: "/codex validate" if chi.respond_to?(:annotation)
|
|
126
|
+
chi.on(:before_turn) do |event|
|
|
127
|
+
@lock.synchronize { @before_turn = Array(event[:messages]) }
|
|
128
|
+
end
|
|
129
|
+
chi.on(:after_turn) do |event|
|
|
130
|
+
answer = completed_answer(event[:messages]) if event[:status].to_s == "completed"
|
|
131
|
+
@lock.synchronize do
|
|
132
|
+
@before_turn = nil
|
|
133
|
+
@last_answer = answer if answer
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Sends +text+ to the paired Codex: the header, then the text inline or
|
|
139
|
+
# a payload file's path. Records it as the last send.
|
|
140
|
+
# @return [String] what was sent ("sent to Codex: validate · title")
|
|
141
|
+
# @raise [SendError]
|
|
142
|
+
def deliver(ctx, kind:, title:, text:)
|
|
143
|
+
raise SendError, "kind must be one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
|
|
144
|
+
raise SendError, "nothing to send: the text is empty" if text.strip.empty?
|
|
145
|
+
if text.length > MAX_CHARS
|
|
146
|
+
raise SendError, "the text is #{text.length} chars, over #{MAX_CHARS}: send a part, not a session"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
id = ctx.session_id or raise SendError, "this session has no id yet: send after its first turn"
|
|
150
|
+
thread = thread_id(ctx)
|
|
151
|
+
title = one_line(title.strip.empty? ? text.strip.lines.first : title)
|
|
152
|
+
body, where = if text.length <= INLINE_CHARS
|
|
153
|
+
[text, "inline"]
|
|
154
|
+
else
|
|
155
|
+
path = write_payload(ctx, id, title, text)
|
|
156
|
+
["payload: #{path} (#{text.lines.size} lines, #{text.length} chars)", path]
|
|
157
|
+
end
|
|
158
|
+
post(thread, "chi #{id[0, 8]} · #{kind} · #{title}\n#{body}")
|
|
159
|
+
record(ctx, id, kind: kind, title: title, chars: text.length, payload: where == "inline" ? nil : where)
|
|
160
|
+
"sent to Codex: #{kind} · #{title}#{" (the text in #{where})" unless where == "inline"}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def codex(args, ctx)
|
|
166
|
+
verb = args.split(/\s+/, 2).first
|
|
167
|
+
# The rest as typed: a pasted selection keeps its lines.
|
|
168
|
+
rest = args.sub(/\A\S+\s*/, "")
|
|
169
|
+
case verb
|
|
170
|
+
when nil then status(ctx)
|
|
171
|
+
when "validate", "context" then rest.strip.empty? ? USAGE : send_text(ctx, verb, rest)
|
|
172
|
+
when "last" then rest.empty? ? send_last(ctx) : USAGE
|
|
173
|
+
when "install-skill" then install_skill(ctx, yes: rest == "--yes")
|
|
174
|
+
else USAGE
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def send_text(ctx, kind, text)
|
|
179
|
+
"#{deliver(ctx, kind: kind, title: text_title(text), text: text)}; Codex's answer comes as the " \
|
|
180
|
+
"codex-review context"
|
|
181
|
+
rescue SendError => e
|
|
182
|
+
"/codex: #{e.message}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A typed text's title: its first line, past a web quote's label line
|
|
186
|
+
# (`From your thinking:`) and the `> ` of a quoted line.
|
|
187
|
+
def text_title(text)
|
|
188
|
+
lines = text.strip.lines.map(&:strip).reject { |line| line.match?(QUOTE_LABEL) }
|
|
189
|
+
lines.map { |line| line.sub(/\A>\s*/, "") }.find { |line| !line.empty? }.to_s
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def send_last(ctx)
|
|
193
|
+
answer, before = @lock.synchronize { [@last_answer, @before_turn] }
|
|
194
|
+
answer ||= completed_answer(before || ctx.messages)
|
|
195
|
+
return "/codex last: no completed answer to send yet (the last turn has none); /codex context <text> sends a text" unless answer
|
|
196
|
+
|
|
197
|
+
deliver(ctx, kind: "context", title: "chi's last answer: #{answer.strip.lines.first}", text: answer)
|
|
198
|
+
rescue SendError => e
|
|
199
|
+
"/codex: #{e.message}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The answer a completed turn left: the history's last message when it
|
|
203
|
+
# is the model's (a cancelled, failed or empty turn ends in a turn note),
|
|
204
|
+
# after any notes that joined between turns; its thinking left out.
|
|
205
|
+
def completed_answer(messages)
|
|
206
|
+
last = Array(messages).reverse.find { |message| !(role(message) == "system" && kind(message) != "turn_note") }
|
|
207
|
+
return nil unless last && role(last) == "model" && !field(last, :interrupted) && !field(last, :tool_calls)
|
|
208
|
+
|
|
209
|
+
text = field(last, :content)
|
|
210
|
+
text = text.filter_map { |part| field(part, :text) if part.is_a?(Hash) }.join("\n") if text.is_a?(Array)
|
|
211
|
+
text = text.to_s.gsub(%r{<think>.*?</think>}m, "").strip
|
|
212
|
+
text.empty? ? nil : text
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The session's attached sources; none when they can't be read (a new
|
|
216
|
+
# session not saved yet; a corrupt session file).
|
|
217
|
+
def sources(ctx)
|
|
218
|
+
ctx.context.list
|
|
219
|
+
rescue ArgumentError, SystemCallError
|
|
220
|
+
[]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def role(message) = field(message, :role).to_s
|
|
224
|
+
def kind(message) = field(message, :kind).to_s
|
|
225
|
+
def field(hash, key) = hash.is_a?(Hash) ? hash[key] || hash[key.to_s] : nil
|
|
226
|
+
|
|
227
|
+
# The status card; nil, so the command adds no text. There is no cheap
|
|
228
|
+
# check that the thread is still there, and a closed Codex session still
|
|
229
|
+
# takes sends (the app-server keeps them; codex queue exits 0).
|
|
230
|
+
def status(ctx)
|
|
231
|
+
id = ctx.session_id
|
|
232
|
+
sources = id ? sources(ctx) : []
|
|
233
|
+
inbox, review = [INBOX, REVIEW].map { |name| sources.find { |source| source[:name] == name } }
|
|
234
|
+
thread = inbox && inbox[:hint].to_s.strip
|
|
235
|
+
paired = !(thread.nil? || thread.empty?)
|
|
236
|
+
lines = if paired
|
|
237
|
+
["**paired** with a Codex session", "thread: `#{thread}` (a closed Codex session still takes sends: they wait " \
|
|
238
|
+
"for `codex resume` there, or pair again)"]
|
|
239
|
+
else
|
|
240
|
+
["**not paired.** In Codex, install the chi-peer skill (Install skill below), then tell Codex " \
|
|
241
|
+
"\"pair with chi #{id.to_s[0, 8]}\"."]
|
|
242
|
+
end
|
|
243
|
+
lines << "last send: #{last_send_line(ctx, id)}" if id && last_send(ctx, id)
|
|
244
|
+
lines << "last answer (codex-review): #{review_line(review)}" if review
|
|
245
|
+
lines << "Sent with `codex queue`: checked on #{CHECKED_ON}."
|
|
246
|
+
ctx.card(id: STATUS_CARD, title: "codex-peer: #{paired ? "paired" : "not paired"}",
|
|
247
|
+
body: lines.join("\n\n"), actions: [{ label: "Refresh", command: "/codex" },
|
|
248
|
+
{ label: "Install skill", command: "/codex install-skill" }])
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def last_send(ctx, id)
|
|
253
|
+
path = File.join(ctx.data_dir, "sessions", id, LAST_SEND)
|
|
254
|
+
File.file?(path) ? JSON.parse(File.read(path)) : nil
|
|
255
|
+
rescue JSON::ParserError, SystemCallError
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def last_send_line(ctx, id)
|
|
260
|
+
sent = last_send(ctx, id)
|
|
261
|
+
where = sent["payload"] ? "in `#{sent["payload"]}`" : "inline"
|
|
262
|
+
"#{ago(sent["at"])} · #{sent["kind"]} · #{sent["title"]} (#{sent["chars"]} chars, #{where})"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def review_line(review)
|
|
266
|
+
return "none yet" unless review[:fetched_at]
|
|
267
|
+
|
|
268
|
+
[review[:summary], ago(review[:fetched_at])].compact.join(" · ")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def ago(stamp)
|
|
272
|
+
seconds = (Time.now - Time.iso8601(stamp.to_s)).to_i
|
|
273
|
+
if seconds < 60 then "just now"
|
|
274
|
+
elsif seconds < 3600 then "#{seconds / 60} min ago"
|
|
275
|
+
elsif seconds < 86_400 then "#{seconds / 3600} h ago"
|
|
276
|
+
else Time.iso8601(stamp).localtime.strftime("%Y-%m-%d %H:%M")
|
|
277
|
+
end
|
|
278
|
+
rescue ArgumentError
|
|
279
|
+
stamp.to_s
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# /codex install-skill [--yes]
|
|
283
|
+
def install_skill(ctx, yes:)
|
|
284
|
+
source = SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
|
|
285
|
+
return "/codex install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install codex-peer --force)" unless source
|
|
286
|
+
|
|
287
|
+
text = File.read(source).gsub(PAIR_PLACEHOLDER, File.join(File.dirname(source), "pair"))
|
|
288
|
+
.gsub(PAYLOADS_PLACEHOLDER, tilde(ctx.data_dir))
|
|
289
|
+
.gsub(CONTEXT_PLACEHOLDER, context_dir(ctx))
|
|
290
|
+
target = File.join(codex_dir, "skills", "chi-peer", "SKILL.md")
|
|
291
|
+
yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def skill_card(ctx, target, text)
|
|
295
|
+
current = File.file?(target) ? File.read(target) : nil
|
|
296
|
+
state = if current.nil? then "Not installed yet."
|
|
297
|
+
elsif current == text then "Installed and up to date."
|
|
298
|
+
else "Installed, and different from this bundle's (Replace overwrites it):\n\n```diff\n#{diff(target, text)}\n```"
|
|
299
|
+
end
|
|
300
|
+
body = ["Codex's side: it pairs (`pair with chi <id>`), answers validates and replies into chi.",
|
|
301
|
+
"target: `#{target}`", state,
|
|
302
|
+
"Codex's default sandbox can't write chi's state, and pairing and replies do: add to " \
|
|
303
|
+
"`#{File.join(codex_dir, "config.toml")}` (into its `[sandbox_workspace_write]` section if it has one), " \
|
|
304
|
+
"then restart Codex:\n\n```toml\n#{roots_line(ctx)}\n```\n\nchi doesn't change Codex's settings."]
|
|
305
|
+
actions = if current == text
|
|
306
|
+
[]
|
|
307
|
+
else
|
|
308
|
+
[{ label: current ? "Replace" : "Install", command: "/codex install-skill --yes" }]
|
|
309
|
+
end
|
|
310
|
+
ctx.card(id: SKILL_CARD, title: "Codex's chi-peer skill", body: body.join("\n\n"), actions: actions)
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def write_skill(ctx, target, text)
|
|
315
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
316
|
+
File.write(target, text)
|
|
317
|
+
ctx.card(id: SKILL_CARD, title: "Codex's chi-peer skill",
|
|
318
|
+
body: "Installed `#{target}`. Restart Codex if it is running, then say \"pair with chi " \
|
|
319
|
+
"#{ctx.session_id.to_s[0, 8]}\".")
|
|
320
|
+
nil
|
|
321
|
+
rescue SystemCallError => e
|
|
322
|
+
"/codex install-skill: couldn't write #{target}: #{e.message}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Codex's config folder: CODEX_HOME, else ~/.codex.
|
|
326
|
+
def codex_dir
|
|
327
|
+
dir = ENV.fetch("CODEX_HOME", "").strip
|
|
328
|
+
dir.empty? ? File.join(Dir.home, ".codex") : File.expand_path(dir)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# chi's context folder (<state>/samagotchi/context, where a push
|
|
332
|
+
# writes), beside the bundle's data dir (<state>/samagotchi/plugins/<name>).
|
|
333
|
+
# Absolute: config.toml doesn't expand ~.
|
|
334
|
+
def context_dir(ctx) = File.expand_path("../../context", ctx.data_dir)
|
|
335
|
+
|
|
336
|
+
def roots_line(ctx) = "[sandbox_workspace_write]\nwritable_roots = [#{JSON.generate(context_dir(ctx))}]"
|
|
337
|
+
|
|
338
|
+
# The first lines of `diff -u installed new`.
|
|
339
|
+
def diff(target, text)
|
|
340
|
+
Tempfile.create(["chi-peer", ".md"]) do |file|
|
|
341
|
+
file.write(text)
|
|
342
|
+
file.flush
|
|
343
|
+
out, = Open3.capture2("diff", "-u", "--label", "installed", "--label", "this bundle's", target, file.path)
|
|
344
|
+
lines = out.lines
|
|
345
|
+
lines = lines.first(DIFF_LINES) + ["… #{lines.size - DIFF_LINES} more lines\n"] if lines.size > DIFF_LINES
|
|
346
|
+
lines.join.chomp
|
|
347
|
+
end
|
|
348
|
+
rescue SystemCallError
|
|
349
|
+
"(diff isn't there to show how)"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def tilde(path)
|
|
353
|
+
home = Dir.home
|
|
354
|
+
path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def follow_up(kind)
|
|
358
|
+
case kind
|
|
359
|
+
when "context" then "Codex takes it in; it answers only if something in it is wrong."
|
|
360
|
+
else "Codex's answer comes as an update of the codex-review context; don't wait or poll."
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# The paired Codex's thread id: the codex-inbox context's hint.
|
|
365
|
+
def thread_id(ctx)
|
|
366
|
+
inbox = sources(ctx).find { |source| source[:name] == INBOX }
|
|
367
|
+
hint = inbox && inbox[:hint].to_s.strip
|
|
368
|
+
if hint.nil? || hint.empty?
|
|
369
|
+
raise SendError, "no codex-inbox: pair from Codex (install its skill with /codex install-skill, then " \
|
|
370
|
+
"tell Codex \"pair with chi #{ctx.session_id.to_s[0, 8]}\")"
|
|
371
|
+
end
|
|
372
|
+
raise SendError, "codex-inbox's hint isn't a Codex thread id (#{hint}): pair again from Codex" unless hint.match?(THREAD_ID)
|
|
373
|
+
|
|
374
|
+
hint
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def codex_command
|
|
378
|
+
command = ENV.fetch("CODEX", "").strip
|
|
379
|
+
command.empty? ? "codex" : command
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Runs `codex queue --thread <thread> --message <text>` (an argv, never a
|
|
383
|
+
# shell line), killing its process group after QUEUE_SECONDS.
|
|
384
|
+
def post(thread, text)
|
|
385
|
+
command = codex_command
|
|
386
|
+
_out, err, status = run(command, "queue", "--thread", thread, "--message", text)
|
|
387
|
+
return if status.success?
|
|
388
|
+
|
|
389
|
+
first = err.lines.map(&:strip).find { |line| !line.empty? }.to_s
|
|
390
|
+
case first
|
|
391
|
+
when /ephemeral/i
|
|
392
|
+
raise SendError, "that Codex session is ephemeral and can't take queued messages: pair from an interactive " \
|
|
393
|
+
"Codex session"
|
|
394
|
+
# Codex 0.162.1: "… failed to read thread: invalid thread-store request:
|
|
395
|
+
# no rollout found for thread id <uuid> (code -32603)". A closed session
|
|
396
|
+
# isn't this: its queue still succeeds.
|
|
397
|
+
when /no rollout found|not found|unknown thread|no such thread|does not exist|no thread/i
|
|
398
|
+
raise SendError, "that Codex session is gone (codex queue: #{first}): pair again from Codex"
|
|
399
|
+
else
|
|
400
|
+
raise SendError, "codex queue failed (exit #{status.exitstatus}): #{first.empty? ? "no error text" : first}"
|
|
401
|
+
end
|
|
402
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOEXEC
|
|
403
|
+
where = command == "codex" ? "no codex on the PATH" : "no Codex CLI at #{command}"
|
|
404
|
+
raise SendError, "#{where}: install Codex CLI, or set CODEX= to its path where chi runs (a worker started by " \
|
|
405
|
+
"chi web or launchd may have another PATH)"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# [stdout, stderr, status]; both pipes read on threads, so a full one
|
|
409
|
+
# can't block the command.
|
|
410
|
+
def run(*command)
|
|
411
|
+
Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
|
|
412
|
+
stdin.close
|
|
413
|
+
out, err = [stdout, stderr].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
|
|
414
|
+
unless wait.join(QUEUE_SECONDS)
|
|
415
|
+
begin
|
|
416
|
+
Process.kill("KILL", -wait.pid)
|
|
417
|
+
rescue SystemCallError
|
|
418
|
+
nil
|
|
419
|
+
end
|
|
420
|
+
# The group is gone, so the pipes end: let the readers finish
|
|
421
|
+
# before popen3 closes them.
|
|
422
|
+
[wait, out, err].each { |t| t.join(1) }
|
|
423
|
+
raise SendError, "Codex didn't take the message in #{QUEUE_SECONDS} s (codex queue was stopped): try " \
|
|
424
|
+
"again, or pair again from Codex"
|
|
425
|
+
end
|
|
426
|
+
[out.value, err.value, wait.value]
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# sessions/<id>/<HHMMSS>-<slug>.md under the bundle's data dir, readable
|
|
431
|
+
# by its owner only.
|
|
432
|
+
def write_payload(ctx, id, title, text)
|
|
433
|
+
dir = session_dir(ctx, id)
|
|
434
|
+
base = "#{Time.now.strftime("%H%M%S")}-#{slug(title)}"
|
|
435
|
+
path = File.join(dir, "#{base}.md")
|
|
436
|
+
n = 1
|
|
437
|
+
path = File.join(dir, "#{base}-#{n += 1}.md") while File.exist?(path)
|
|
438
|
+
File.open(path, "w", 0o600) { |file| file.write(text) }
|
|
439
|
+
path
|
|
440
|
+
rescue SystemCallError => e
|
|
441
|
+
raise SendError, "couldn't write the payload file: #{e.message}"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def session_dir(ctx, id)
|
|
445
|
+
File.join(ctx.data_dir, "sessions", id).tap { |dir| FileUtils.mkdir_p(dir) }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def record(ctx, id, **send)
|
|
449
|
+
entry = { at: Time.now.utc.iso8601, **send }
|
|
450
|
+
@lock.synchronize { File.write(File.join(session_dir(ctx, id), LAST_SEND), JSON.generate(entry)) }
|
|
451
|
+
rescue SystemCallError => e
|
|
452
|
+
ctx.log.info(:last_send_not_saved, error: e.message)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def slug(title)
|
|
456
|
+
slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")[0, SLUG_CHARS].delete_suffix("-")
|
|
457
|
+
slug.empty? ? "part" : slug
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def one_line(text)
|
|
461
|
+
line = text.to_s.gsub(/\s+/, " ").strip
|
|
462
|
+
line.length > TITLE_CHARS ? "#{line[0, TITLE_CHARS - 1]}…" : line
|
|
463
|
+
end
|
|
464
|
+
end
|