rubino-agent 0.3.0 → 0.4.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 +35 -4
- data/docs/commands.md +7 -2
- data/docs/skills.md +31 -0
- data/install.sh +6 -5
- data/lib/rubino/attachments/classify.rb +35 -17
- data/lib/rubino/cli/commands.rb +1 -1
- data/lib/rubino/cli/skills_command.rb +129 -2
- data/lib/rubino/commands/handlers/agents.rb +23 -3
- data/lib/rubino/context/environment_inspector.rb +2 -2
- data/lib/rubino/context/file_discovery.rb +2 -2
- data/lib/rubino/context/prompt_assembler.rb +1 -1
- data/lib/rubino/skills/installer.rb +146 -0
- data/lib/rubino/skills/registry.rb +10 -0
- data/lib/rubino/skills/skill.rb +3 -3
- data/lib/rubino/tools/background_tasks.rb +35 -4
- data/lib/rubino/ui/subagent_view.rb +15 -4
- data/lib/rubino/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a20b85c30f537ff5bdaf5abec6300eedfcda791d76b899ac3fcd972b71854ac1
|
|
4
|
+
data.tar.gz: d5692a31f13e338690a3be90bc4d96d27251ddad165b706afee7e0fcdee3fea7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 865a203c91311f039d90a98b4e88ff8f792d0049e7370f10c847e2aeb77458f2f39d80485f133a79d8c2e8d9c1eeb5dec369f53320d210ff79928965a8c0a3f2
|
|
7
|
+
data.tar.gz: a9435d6746bc743e65db73574e13d4cd65445bf97ec5c9e2387737c176e7ab8726991062cc852a6c295910e3876d8b4a1d8c96420f5a8dd6a83dabf68a280546
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-06-13
|
|
6
|
+
|
|
7
|
+
### Added — skills from git (#4)
|
|
8
|
+
|
|
9
|
+
- **`rubino skills install <owner/repo | git-URL>`** — install skills from any
|
|
10
|
+
git repo shipping the `<name>/SKILL.md` layout (`--skill NAME` / `--all` /
|
|
11
|
+
`--list`; `--documents` is shorthand for the four `anthropics/skills`
|
|
12
|
+
document skills). Provenance lands in `~/.rubino/skills/.sources.json`, so
|
|
13
|
+
**`rubino skills update`** re-fetches from the recorded source (up-to-date vs
|
|
14
|
+
updated by commit) and **`rubino skills remove NAME`** only deletes what this
|
|
15
|
+
mechanism installed. `rubino skills list` gains a Source column.
|
|
16
|
+
- The skill registry now also discovers the agent-neutral `.agents/skills/`
|
|
17
|
+
and `~/.agents/skills/` dirs (the `npx skills` / Gemini CLI convention) —
|
|
18
|
+
additive, lowest precedence, trust-gated like `.rubino/skills`.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`/agents <id>` watch — live tool-output tail (#5).** The drill-in watch grows an `output:` block showing the tail of the running subagent's current tool output, clearing when the tool finishes.
|
|
23
|
+
- **`soffice` and `qpdf` in the `[Environment]` probe (#4/#6)** so the agent honestly reports whether LibreOffice/qpdf are available for the document skills.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`read_attachment` extension-spoof gate now covers document MIMEs (#239).** A text file named `report.docx` reads inline as text instead of bouncing off the document converter; a real `.docx` (ZIP magic) still classifies as a document.
|
|
28
|
+
- **No more CLI crash under a C/POSIX locale (#250).** Skill and context files are read as UTF-8 rather than the ambient (US-ASCII) encoding, so `rubino skills list` and prompt assembly no longer raise `invalid byte sequence` on minimal Linux/Docker images.
|
|
29
|
+
- **Installer no longer always exits 1 on a fresh Linux box (#240).** Fixed an unbound `rv_bin` under `set -u` and an invalid `gem environment gembindir` call; `curl … | bash` now installs cleanly and is idempotent.
|
|
30
|
+
|
|
31
|
+
### Internal
|
|
32
|
+
|
|
33
|
+
- **Test stability (#236):** PTY capture specs read to the child's EOF instead of treating a 0.5s quiet window as end-of-output, removing a rare 2-failure flake under concurrent suite load.
|
|
34
|
+
- **Approval-handoff guard (#10):** the #80 unit guard now genuinely fails on a full revert; the PTY handoff spec is relabeled as a happy-path check.
|
|
35
|
+
|
|
36
|
+
## [0.3.0] - 2026-06-06
|
|
37
|
+
|
|
38
|
+
Major capability release: the core conversation loop was ported 1:1 from the reference implementation (formalized LLM boundary, retry/backoff/fallback, degenerate-response recovery), background subagents became the default delegation path, the memory subsystem grew a pluggable backend contract with a tiny-Zep SQLite backend that is now the default, CLI gained image/file input and a scroll-native redesign, and a reference-aligned approval model (hardline floor, dangerous-pattern deny, prefix-derived rules) landed. Consolidated from `feature/subagent-view` (#48) plus #49-#58.
|
|
39
|
+
|
|
5
40
|
### Added — CLI redesign & in-chat surfaces
|
|
6
41
|
|
|
7
42
|
A scroll-native `rubino chat` refresh plus several new slash commands and input affordances. All are documented under [docs/commands.md](docs/commands.md) and [docs/configuration.md](docs/configuration.md).
|
|
@@ -107,10 +142,6 @@ The GitHub repository is `github.com/Jhonnyr97/rubino-agent`. Publishing the ren
|
|
|
107
142
|
|
|
108
143
|
- `docs/api/v1.md` aligned to the real API surface (#165, #166, #167): SSE catalogue documents the non-streaming contract (no `message.delta`/`reasoning.delta`), the approval decision enum lists all seven accepted values with semantics, and `GET /v1/sessions`, `/v1/memory*`, `/v1/tasks*` are documented. A doc-drift spec locks the documented route list to the registered routes.
|
|
109
144
|
|
|
110
|
-
## [0.3.0] - 2026-06-06
|
|
111
|
-
|
|
112
|
-
Major capability release: the core conversation loop was ported 1:1 from the reference implementation (formalized LLM boundary, retry/backoff/fallback, degenerate-response recovery), background subagents became the default delegation path, the memory subsystem grew a pluggable backend contract with a tiny-Zep SQLite backend that is now the default, CLI gained image/file input and a scroll-native redesign, and a reference-aligned approval model (hardline floor, dangerous-pattern deny, prefix-derived rules) landed. Consolidated from `feature/subagent-view` (#48) plus #49-#58.
|
|
113
|
-
|
|
114
145
|
### Breaking / upgrade notes
|
|
115
146
|
|
|
116
147
|
- **Default memory backend is now SQLite (tiny-Zep).** `memory.backend` now defaults to `"sqlite"` (previously `"default"`). The new backend reads/writes the `:memory_facts` table; the old `"default"` backend used the `:memories` table. On upgrade, users who were on the previous `"default"` backend and do **not** pin `memory.backend: "default"` in their config will stop reading their prior memory store — the new backend looks only at `:memory_facts`. Your old data in `:memories` is **not deleted**, just no longer read. **No automatic backfill is shipped.** To keep old recall, pin `memory.backend: "default"` in config. (Acceptable for alpha; documented here.)
|
data/docs/commands.md
CHANGED
|
@@ -13,7 +13,7 @@ Two surfaces: **CLI subcommands** (run from your shell) and **slash commands** (
|
|
|
13
13
|
| `rubino memory SUBCOMMAND` | Manage persistent memories (`list` / `show` / `delete` / `backend`) |
|
|
14
14
|
| `rubino sessions SUBCOMMAND` | Manage chat sessions |
|
|
15
15
|
| `rubino jobs SUBCOMMAND` | Manage background jobs |
|
|
16
|
-
| `rubino skills SUBCOMMAND` | Manage skills (`list` / `show` / `enable` / `disable`) |
|
|
16
|
+
| `rubino skills SUBCOMMAND` | Manage skills (`list` / `show` / `enable` / `disable` / `install` / `update` / `remove`) |
|
|
17
17
|
| `rubino tools` | List available tools and their enabled/disabled state |
|
|
18
18
|
| `rubino server` | Start the JSON API + SSE server |
|
|
19
19
|
| `rubino tls-cert` | Print the agent's self-signed TLS certificate PEM (generating it if absent) |
|
|
@@ -129,10 +129,15 @@ rubino jobs list
|
|
|
129
129
|
rubino jobs process # run pending jobs now (manual mode)
|
|
130
130
|
rubino jobs worker # start a background worker
|
|
131
131
|
|
|
132
|
-
rubino skills list # list skills with enabled/disabled markers
|
|
132
|
+
rubino skills list # list skills with enabled/disabled markers + provenance
|
|
133
133
|
rubino skills show NAME # print a skill's SKILL.md body (review before enabling)
|
|
134
134
|
rubino skills enable NAME # put a skill back in the index (every session)
|
|
135
135
|
rubino skills disable NAME # drop a skill from the index (every session)
|
|
136
|
+
|
|
137
|
+
rubino skills install owner/repo --skill NAME # install skills from a git repo (#4)
|
|
138
|
+
rubino skills install --documents # anthropics/skills: pdf docx pptx xlsx
|
|
139
|
+
rubino skills update [NAME ...] # re-fetch from the recorded sources
|
|
140
|
+
rubino skills remove NAME # delete an installed skill + provenance
|
|
136
141
|
```
|
|
137
142
|
|
|
138
143
|
`config get`/`config show` mask secret-named keys (`api_key`, tokens, …) on display — the file keeps the real value. See [memory.md](memory.md) for the memory backends, [jobs.md](jobs.md) for the queue/cron system and [skills.md](skills.md) for the skill model.
|
data/docs/skills.md
CHANGED
|
@@ -31,6 +31,12 @@ skills:
|
|
|
31
31
|
Override the search paths via the `skills.paths` config key. On a name collision
|
|
32
32
|
the **directory** layout wins over the flat-file layout (it is the richer unit).
|
|
33
33
|
|
|
34
|
+
The registry additionally scans the **agent-neutral** skill dirs — project
|
|
35
|
+
`.agents/skills/` and `~/.agents/skills/` (the emerging cross-agent convention
|
|
36
|
+
used by `npx skills`, Gemini CLI, goose) — at the lowest precedence: a
|
|
37
|
+
rubino-path skill of the same name wins, and nothing changes when those dirs
|
|
38
|
+
are absent. The project-local one is trust-gated exactly like `.rubino/skills`.
|
|
39
|
+
|
|
34
40
|
### Two layouts
|
|
35
41
|
|
|
36
42
|
| Layout | Path | Skill name | Bundled files |
|
|
@@ -59,6 +65,31 @@ skills:
|
|
|
59
65
|
include_builtin: false # default true
|
|
60
66
|
```
|
|
61
67
|
|
|
68
|
+
### Installing skills from git (`rubino skills install`)
|
|
69
|
+
|
|
70
|
+
Any git repo shipping the `<name>/SKILL.md` layout is a skill source — there is
|
|
71
|
+
no marketplace and nothing else is vendored in the gem. Skills are
|
|
72
|
+
shallow-cloned and copied into `~/.rubino/skills`, where the registry discovers
|
|
73
|
+
them like any hand-written skill:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
rubino skills install anthropics/skills --list # see what a source ships
|
|
77
|
+
rubino skills install anthropics/skills --skill pdf # pick by name (repeatable)
|
|
78
|
+
rubino skills install owner/repo --all # take everything
|
|
79
|
+
rubino skills install https://gitlab.com/o/r.git # any git URL works
|
|
80
|
+
rubino skills install --documents # anthropics/skills: pdf docx pptx xlsx
|
|
81
|
+
rubino skills update # re-fetch installed skills
|
|
82
|
+
rubino skills remove NAME # delete dir + provenance
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
With no `--skill`/`--all` and multiple skills in the source, the CLI prints the
|
|
86
|
+
catalogue and asks you to pick (off a TTY it just prints the hint). Provenance
|
|
87
|
+
is recorded per installed skill in `~/.rubino/skills/.sources.json`
|
|
88
|
+
(`name → {source, path, commit}`): `rubino skills list` shows it in the Source
|
|
89
|
+
column, and `update` re-fetches from the recorded source, reporting
|
|
90
|
+
*up to date* vs *updated* by comparing commits. `remove` only deletes skills
|
|
91
|
+
this mechanism installed — hand-written skills are never touched.
|
|
92
|
+
|
|
62
93
|
### Authoring a `SKILL.md`
|
|
63
94
|
|
|
64
95
|
A `SKILL.md` is YAML frontmatter followed by the instruction body:
|
data/install.sh
CHANGED
|
@@ -144,7 +144,7 @@ setup_ruby_rv() {
|
|
|
144
144
|
return 1
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
local
|
|
147
|
+
# NOTE: rv_bin is intentionally NOT local: rubyx() reads it after we return.
|
|
148
148
|
if rv_bin="$(locate_rv)"; then
|
|
149
149
|
ok "rv already installed: ${rv_bin}"
|
|
150
150
|
else
|
|
@@ -198,10 +198,11 @@ case "$METHOD" in
|
|
|
198
198
|
*) die "internal: unknown method '${METHOD}'." ;;
|
|
199
199
|
esac
|
|
200
200
|
|
|
201
|
-
# Where gem-installed executables land for the chosen Ruby.
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
# Where gem-installed executables land for the chosen Ruby. Ask RubyGems
|
|
202
|
+
# directly (Gem.bindir); fall back to the ruby bin dir. The dir may not exist
|
|
203
|
+
# yet on a fresh machine — `gem install` creates it — so don't require it.
|
|
204
|
+
GEM_BIN_DIR="$(rubyx ruby -e 'print Gem.bindir' 2>/dev/null)" || GEM_BIN_DIR=""
|
|
205
|
+
[ -n "${GEM_BIN_DIR:-}" ] || GEM_BIN_DIR="$RUBY_BIN_DIR"
|
|
205
206
|
|
|
206
207
|
# --- install the rubino gem -------------------------------------------------
|
|
207
208
|
|
|
@@ -35,19 +35,35 @@ module Rubino
|
|
|
35
35
|
].freeze
|
|
36
36
|
IMAGE_EXTS = %w[.png .jpg .jpeg .gif .webp .bmp .tiff .tif].freeze
|
|
37
37
|
|
|
38
|
-
# Leading magic bytes per recognised image MIME (WebP is
|
|
39
|
-
# RIFF container + WEBP tag). Marcel lets the file NAME
|
|
40
|
-
# when the content sniff only yields a generic type
|
|
41
|
-
# octet-stream), so a text file renamed fake.png came back
|
|
42
|
-
# was shipped to the provider (#158)
|
|
43
|
-
#
|
|
44
|
-
|
|
38
|
+
# Leading magic bytes per recognised image/document MIME (WebP is
|
|
39
|
+
# special-cased: RIFF container + WEBP tag). Marcel lets the file NAME
|
|
40
|
+
# break the tie when the content sniff only yields a generic type
|
|
41
|
+
# (text/plain, octet-stream), so a text file renamed fake.png came back
|
|
42
|
+
# image/png and was shipped to the provider (#158) — and a text file
|
|
43
|
+
# renamed report.docx came back as :document and got a shell-hint
|
|
44
|
+
# instead of reading inline (#239). An image or document verdict must
|
|
45
|
+
# therefore be backed by the actual signature.
|
|
46
|
+
OLE2 = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b
|
|
47
|
+
SIGNATURES = {
|
|
45
48
|
"image/png" => ["\x89PNG\r\n\x1a\n".b],
|
|
46
49
|
"image/jpeg" => ["\xFF\xD8\xFF".b],
|
|
47
50
|
"image/gif" => ["GIF87a".b, "GIF89a".b],
|
|
48
51
|
"image/bmp" => ["BM".b],
|
|
49
52
|
"image/x-ms-bmp" => ["BM".b],
|
|
50
|
-
"image/tiff" => ["II*\x00".b, "MM\x00*".b]
|
|
53
|
+
"image/tiff" => ["II*\x00".b, "MM\x00*".b],
|
|
54
|
+
"application/pdf" => ["%PDF".b],
|
|
55
|
+
# OOXML and ODF are ZIP containers.
|
|
56
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ["PK".b],
|
|
57
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ["PK".b],
|
|
58
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => ["PK".b],
|
|
59
|
+
"application/vnd.oasis.opendocument.text" => ["PK".b],
|
|
60
|
+
"application/vnd.oasis.opendocument.spreadsheet" => ["PK".b],
|
|
61
|
+
# Legacy Office is an OLE2 compound file.
|
|
62
|
+
"application/msword" => [OLE2],
|
|
63
|
+
"application/vnd.ms-excel" => [OLE2],
|
|
64
|
+
"application/vnd.ms-powerpoint" => [OLE2],
|
|
65
|
+
"application/rtf" => ["{\\rtf".b],
|
|
66
|
+
"text/rtf" => ["{\\rtf".b]
|
|
51
67
|
}.freeze
|
|
52
68
|
|
|
53
69
|
module_function
|
|
@@ -97,12 +113,14 @@ module Rubino
|
|
|
97
113
|
basename = File.basename(real)
|
|
98
114
|
mime = Marcel::MimeType.for(Pathname(real), name: basename).to_s
|
|
99
115
|
|
|
100
|
-
# Extension-spoof gate (#158): an image verdict that
|
|
101
|
-
# don't back up came from the extension, not the
|
|
102
|
-
# from content alone (no name:); when that is
|
|
103
|
-
# binary sniff names the honest type — so
|
|
104
|
-
# rejected at the staging gate as text/plain
|
|
105
|
-
|
|
116
|
+
# Extension-spoof gate (#158, #239): an image or document verdict that
|
|
117
|
+
# the magic bytes don't back up came from the extension, not the
|
|
118
|
+
# content. Re-resolve from content alone (no name:); when that is
|
|
119
|
+
# generic too, the text/binary sniff names the honest type — so
|
|
120
|
+
# fake.png full of text is rejected at the staging gate as text/plain
|
|
121
|
+
# before any network call, and report.docx full of text reads inline
|
|
122
|
+
# as text instead of bouncing off the document converter.
|
|
123
|
+
if (IMAGE_MIMES.include?(mime) || DOCUMENT_MIMES.include?(mime)) && !signature?(real, mime)
|
|
106
124
|
mime = Marcel::MimeType.for(Pathname(real)).to_s
|
|
107
125
|
if mime.empty? || mime == "application/octet-stream"
|
|
108
126
|
return base_helper.send(:binary?, real) ? [:binary, "application/octet-stream"] : [:text, "text/plain"]
|
|
@@ -136,12 +154,12 @@ module Rubino
|
|
|
136
154
|
end
|
|
137
155
|
|
|
138
156
|
# True when the file's leading bytes carry the signature +mime+ claims.
|
|
139
|
-
#
|
|
140
|
-
def
|
|
157
|
+
# MIMEs without a known signature fail closed (not verified).
|
|
158
|
+
def signature?(real, mime)
|
|
141
159
|
head = File.binread(real, 16).to_s.b
|
|
142
160
|
return head.start_with?("RIFF") && head[8, 4] == "WEBP" if mime == "image/webp"
|
|
143
161
|
|
|
144
|
-
Array(
|
|
162
|
+
Array(SIGNATURES[mime]).any? { |sig| head.start_with?(sig) }
|
|
145
163
|
end
|
|
146
164
|
|
|
147
165
|
# JSON/XML/YAML/JS and friends arrive as application/* but are text.
|
data/lib/rubino/cli/commands.rb
CHANGED
|
@@ -183,7 +183,7 @@ module Rubino
|
|
|
183
183
|
desc "jobs SUBCOMMAND", "Manage background jobs"
|
|
184
184
|
subcommand "jobs", JobsCommand
|
|
185
185
|
|
|
186
|
-
desc "skills SUBCOMMAND", "Manage skills (list,
|
|
186
|
+
desc "skills SUBCOMMAND", "Manage skills (list, enable, install, update)"
|
|
187
187
|
subcommand "skills", SkillsCommand
|
|
188
188
|
|
|
189
189
|
desc "tools", "List available tools"
|
|
@@ -10,10 +10,18 @@ module Rubino
|
|
|
10
10
|
# run the SAME registry-validated StateRepository write the HTTP API
|
|
11
11
|
# toggle and the in-chat `/skills enable|disable` use (Skills::Toggle) —
|
|
12
12
|
# no new logic, just the missing terminal surface.
|
|
13
|
+
#
|
|
14
|
+
# `install`/`update`/`remove` (#4) manage skills fetched from git repos
|
|
15
|
+
# (Skills::Installer): any repo shipping the registry's `<name>/SKILL.md`
|
|
16
|
+
# layout is a source — no marketplace, nothing vendored in the gem.
|
|
13
17
|
class SkillsCommand < Thor
|
|
14
18
|
# Clean `tree`/help label instead of the underscored class-name default (F12).
|
|
15
19
|
namespace "rubino skills"
|
|
16
20
|
|
|
21
|
+
# The `--documents` shorthand (#4): Anthropic's four document skills.
|
|
22
|
+
DOCUMENTS_SOURCE = "anthropics/skills"
|
|
23
|
+
DOCUMENT_SKILLS = %w[pdf docx pptx xlsx].freeze
|
|
24
|
+
|
|
17
25
|
def self.exit_on_failure?
|
|
18
26
|
true
|
|
19
27
|
end
|
|
@@ -29,10 +37,12 @@ module Rubino
|
|
|
29
37
|
return
|
|
30
38
|
end
|
|
31
39
|
|
|
40
|
+
sources = Skills::Installer.new.sources
|
|
32
41
|
rows = skills.map do |skill|
|
|
33
|
-
[skill.name, skill_status(skill.name, registry), skill.
|
|
42
|
+
[skill.name, skill_status(skill.name, registry), provenance(skill.name, sources),
|
|
43
|
+
skill.description.to_s]
|
|
34
44
|
end
|
|
35
|
-
Rubino.ui.table(headers: %w[Name Status Description], rows: rows)
|
|
45
|
+
Rubino.ui.table(headers: %w[Name Status Source Description], rows: rows)
|
|
36
46
|
end
|
|
37
47
|
|
|
38
48
|
desc "show NAME", "Print a skill's SKILL.md body (review it before enabling)"
|
|
@@ -56,6 +66,70 @@ module Rubino
|
|
|
56
66
|
toggle(name, enabled: false)
|
|
57
67
|
end
|
|
58
68
|
|
|
69
|
+
desc "install [SOURCE]", "Install skills from a git repo (owner/repo shorthand or git URL)"
|
|
70
|
+
option :skill, type: :string, repeatable: true,
|
|
71
|
+
desc: "Skill name to install from the source (repeatable)"
|
|
72
|
+
option :all, type: :boolean, desc: "Install every skill found in the source"
|
|
73
|
+
option :list, type: :boolean, desc: "Only list the skills discoverable in the source"
|
|
74
|
+
option :documents, type: :boolean,
|
|
75
|
+
desc: "Shorthand for #{DOCUMENTS_SOURCE} with #{DOCUMENT_SKILLS.join("/")}"
|
|
76
|
+
def install(source = nil)
|
|
77
|
+
wanted = Array(options[:skill])
|
|
78
|
+
if options[:documents]
|
|
79
|
+
source ||= DOCUMENTS_SOURCE
|
|
80
|
+
wanted = DOCUMENT_SKILLS.dup if wanted.empty?
|
|
81
|
+
end
|
|
82
|
+
if source.nil?
|
|
83
|
+
Rubino.ui.error("missing source — pass owner/repo, a git URL, or --documents")
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
installer = Skills::Installer.new
|
|
88
|
+
fetched = installer.fetch(source) do |checkout, sha|
|
|
89
|
+
found = installer.discover(checkout)
|
|
90
|
+
if found.empty?
|
|
91
|
+
Rubino.ui.warning("no skills found in #{source} (expected <name>/SKILL.md directories)")
|
|
92
|
+
elsif options[:list]
|
|
93
|
+
discovered_table(found)
|
|
94
|
+
else
|
|
95
|
+
install_selected(installer, found, wanted, checkout: checkout, source: source, commit: sha)
|
|
96
|
+
end
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
Rubino.ui.error("could not fetch #{source} — check the source name/URL and your network") if fetched.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
desc "update [NAME ...]", "Re-fetch installed skills from their recorded sources"
|
|
103
|
+
def update(*names)
|
|
104
|
+
installer = Skills::Installer.new
|
|
105
|
+
if installer.sources.empty?
|
|
106
|
+
Rubino.ui.info("No skills installed via `rubino skills install` yet.")
|
|
107
|
+
return
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
installer.update(names).each do |name, status|
|
|
111
|
+
case status
|
|
112
|
+
when :updated then Rubino.ui.success("Updated skill: #{name}")
|
|
113
|
+
when :up_to_date then Rubino.ui.info("#{name} is up to date.")
|
|
114
|
+
when :unknown then Rubino.ui.error("unknown skill: #{name} (not installed via `rubino skills install`)")
|
|
115
|
+
else Rubino.ui.error("could not update #{name} — fetch failed or the skill left its source")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
desc "remove NAME", "Remove a skill installed via `rubino skills install`"
|
|
121
|
+
def remove(name)
|
|
122
|
+
installer = Skills::Installer.new
|
|
123
|
+
if installer.remove(name)
|
|
124
|
+
Rubino.ui.success("Removed skill: #{name}")
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Rubino.ui.error("#{name} wasn't installed via `rubino skills install` (no provenance entry)")
|
|
129
|
+
dir = File.join(installer.skills_dir, name)
|
|
130
|
+
Rubino.ui.info("It exists at #{dir} — delete the directory manually.") if File.directory?(dir)
|
|
131
|
+
end
|
|
132
|
+
|
|
59
133
|
private
|
|
60
134
|
|
|
61
135
|
# The Status cell: enabled/disabled from the StateRepository (the same
|
|
@@ -68,6 +142,13 @@ module Rubino
|
|
|
68
142
|
status
|
|
69
143
|
end
|
|
70
144
|
|
|
145
|
+
# The Source cell: where an installed skill came from (provenance ledger),
|
|
146
|
+
# blank for built-in / hand-written skills.
|
|
147
|
+
def provenance(name, sources)
|
|
148
|
+
entry = sources[name]
|
|
149
|
+
entry ? "#{entry["source"]} @ #{entry["commit"].to_s[0, 7]}" : ""
|
|
150
|
+
end
|
|
151
|
+
|
|
71
152
|
def toggle(name, enabled:)
|
|
72
153
|
Rubino.ensure_database_ready!
|
|
73
154
|
registry = Skills::Registry.trusted
|
|
@@ -80,6 +161,52 @@ module Rubino
|
|
|
80
161
|
|
|
81
162
|
Rubino.ui.success("#{enabled ? "Enabled" : "Disabled"} skill: #{name}")
|
|
82
163
|
end
|
|
164
|
+
|
|
165
|
+
# Resolves which of the discovered skills to install: explicit --skill
|
|
166
|
+
# names (all-or-nothing — a typo aborts rather than half-installing),
|
|
167
|
+
# --all, the only skill found, or an interactive pick. Off a TTY the
|
|
168
|
+
# picker returns nil and the multi-skill case degrades to the catalogue
|
|
169
|
+
# plus a --skill/--all hint.
|
|
170
|
+
def install_selected(installer, found, wanted, checkout:, source:, commit:)
|
|
171
|
+
chosen =
|
|
172
|
+
if wanted.any?
|
|
173
|
+
missing = wanted - found.map { |e| e[:name] }
|
|
174
|
+
unless missing.empty?
|
|
175
|
+
Rubino.ui.error("not found in #{source}: #{missing.join(", ")}")
|
|
176
|
+
Rubino.ui.info("Available: #{found.map { |e| e[:name] }.join(", ")}")
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
found.select { |e| wanted.include?(e[:name]) }
|
|
180
|
+
elsif options[:all] || found.size == 1
|
|
181
|
+
found
|
|
182
|
+
else
|
|
183
|
+
pick_one(found)
|
|
184
|
+
end
|
|
185
|
+
return if chosen.nil?
|
|
186
|
+
|
|
187
|
+
installer.install(chosen, checkout: checkout, source: source, commit: commit)
|
|
188
|
+
chosen.each { |e| Rubino.ui.success("Installed skill: #{e[:name]} (#{source} @ #{commit[0, 7]})") }
|
|
189
|
+
Rubino.ui.status("Installed into #{installer.skills_dir}")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Multiple skills, none selected: print the catalogue and ask to pick
|
|
193
|
+
# (the same arrow-key picker /sessions resume uses). nil when there is
|
|
194
|
+
# no real terminal or the pick is cancelled.
|
|
195
|
+
def pick_one(found)
|
|
196
|
+
discovered_table(found)
|
|
197
|
+
picked = Rubino.ui.select("Install which skill?", found.map { |e| [e[:name], e] })
|
|
198
|
+
if picked.nil?
|
|
199
|
+
Rubino.ui.info("Multiple skills found — pass --skill NAME (repeatable) or --all.")
|
|
200
|
+
return nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
[picked]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def discovered_table(found)
|
|
207
|
+
rows = found.map { |e| [e[:name], e[:path], e[:description]] }
|
|
208
|
+
Rubino.ui.table(headers: %w[Name Path Description], rows: rows)
|
|
209
|
+
end
|
|
83
210
|
end
|
|
84
211
|
end
|
|
85
212
|
end
|
|
@@ -299,9 +299,10 @@ module Rubino
|
|
|
299
299
|
watch_loop(entry.id)
|
|
300
300
|
end
|
|
301
301
|
|
|
302
|
-
# Renders ONE watch frame: header + task + the recent: ring
|
|
303
|
-
# snapshot shape reused per refresh tick. The
|
|
304
|
-
# bounded activity_log, plus the live
|
|
302
|
+
# Renders ONE watch frame: header + task + the recent: ring + the live
|
|
303
|
+
# output: tail. Public-ish snapshot shape reused per refresh tick. The
|
|
304
|
+
# recent ring is the registry's bounded activity_log, plus the live
|
|
305
|
+
# last_activity as the trailing ● line.
|
|
305
306
|
def render_agent_watch(entry)
|
|
306
307
|
@ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent} · #{agent_elapsed(entry)}")
|
|
307
308
|
@ui.info("task: #{truncate(entry.prompt, 120)}")
|
|
@@ -309,6 +310,25 @@ module Rubino
|
|
|
309
310
|
Array(entry.activity_log).last(5).each { |line| @ui.info(" #{line}") }
|
|
310
311
|
last = entry.last_activity.to_s
|
|
311
312
|
@ui.info(" #{pastel.yellow("●")} #{last}") unless last.empty?
|
|
313
|
+
render_agent_output_tail(entry)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# #5 — the live output: block under the ring: the tail of the CURRENTLY
|
|
317
|
+
# RUNNING tool's streamed output (the registry's bounded output_tail,
|
|
318
|
+
# fed by the child's UI::SubagentView#tool_chunk and wiped at
|
|
319
|
+
# tool_finished), so a long shell call shows its lines as they print
|
|
320
|
+
# instead of a frozen frame. Renders nothing when no tool is mid-run or
|
|
321
|
+
# it hasn't produced output yet; the buffer's empty last slot just means
|
|
322
|
+
# the latest line is complete, so it is dropped, not rendered.
|
|
323
|
+
def render_agent_output_tail(entry)
|
|
324
|
+
lines = Array(entry.output_tail)
|
|
325
|
+
lines = lines[0..-2] if lines.last.to_s.empty?
|
|
326
|
+
return if lines.empty?
|
|
327
|
+
|
|
328
|
+
@ui.info("output:")
|
|
329
|
+
lines.last(Tools::BackgroundTasks::OUTPUT_TAIL_MAX).each do |line|
|
|
330
|
+
@ui.info(" #{pastel.dim("│")} #{truncate(line, 120)}")
|
|
331
|
+
end
|
|
312
332
|
end
|
|
313
333
|
|
|
314
334
|
# The live refresh loop for #watch_agent. Polls the registry and re-renders
|
|
@@ -26,7 +26,7 @@ module Rubino
|
|
|
26
26
|
git gh rg jq curl wget
|
|
27
27
|
ruby python3 node npm bundle
|
|
28
28
|
docker psql sqlite3 redis-cli
|
|
29
|
-
ffmpeg pandoc markitdown pdftotext tesseract
|
|
29
|
+
ffmpeg pandoc markitdown pdftotext tesseract soffice qpdf
|
|
30
30
|
].freeze
|
|
31
31
|
|
|
32
32
|
class << self
|
|
@@ -114,7 +114,7 @@ module Rubino
|
|
|
114
114
|
def linux_distro
|
|
115
115
|
return nil unless File.readable?("/etc/os-release")
|
|
116
116
|
|
|
117
|
-
pretty = File.read("/etc/os-release").lines.find { |l| l.start_with?("PRETTY_NAME=") }
|
|
117
|
+
pretty = File.read("/etc/os-release", encoding: "UTF-8").lines.find { |l| l.start_with?("PRETTY_NAME=") }
|
|
118
118
|
pretty&.split("=", 2)&.last&.strip&.delete('"')
|
|
119
119
|
rescue StandardError
|
|
120
120
|
nil
|
|
@@ -22,7 +22,7 @@ module Rubino
|
|
|
22
22
|
files = discover_files
|
|
23
23
|
return nil if files.empty?
|
|
24
24
|
|
|
25
|
-
files.map { |f| File.read(f) }.join("\n\n---\n\n")
|
|
25
|
+
files.map { |f| File.read(f, encoding: "UTF-8") }.join("\n\n---\n\n")
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Returns list of discovered context file paths
|
|
@@ -37,7 +37,7 @@ module Rubino
|
|
|
37
37
|
def local_context(subdir)
|
|
38
38
|
CONTEXT_FILES.filter_map do |filename|
|
|
39
39
|
path = File.join(@base_path, subdir, filename)
|
|
40
|
-
File.read(path) if File.exist?(path)
|
|
40
|
+
File.read(path, encoding: "UTF-8") if File.exist?(path)
|
|
41
41
|
end.join("\n")
|
|
42
42
|
end
|
|
43
43
|
end
|
|
@@ -339,7 +339,7 @@ module Rubino
|
|
|
339
339
|
|
|
340
340
|
def load_builtin_prompt(name)
|
|
341
341
|
path = File.expand_path("../agent/prompts/#{name}.txt", __dir__)
|
|
342
|
-
File.exist?(path) ? File.read(path).strip : nil
|
|
342
|
+
File.exist?(path) ? File.read(path, encoding: "UTF-8").strip : nil
|
|
343
343
|
rescue StandardError
|
|
344
344
|
nil
|
|
345
345
|
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Skills
|
|
8
|
+
# Installs skills from git repositories into the user skills dir (#4) —
|
|
9
|
+
# the `rubino skills install/update/remove` backend. There is no
|
|
10
|
+
# marketplace and nothing is vendored in the gem: a source is just a repo
|
|
11
|
+
# (GitHub `owner/repo` shorthand or any git URL), shallow-cloned to a
|
|
12
|
+
# tmpdir, scanned for the registry's own `<name>/SKILL.md` layout, and the
|
|
13
|
+
# selected skill dirs are copied into `~/.rubino/skills` where the
|
|
14
|
+
# existing Registry discovers them like any hand-written skill.
|
|
15
|
+
#
|
|
16
|
+
# Provenance is recorded per installed skill in `<skills-dir>/.sources.json`
|
|
17
|
+
# (`name → {source, path, commit}`) so `update` can re-fetch from the
|
|
18
|
+
# recorded source and `remove` knows which dirs this mechanism owns. The
|
|
19
|
+
# dotfile name keeps it out of the registry's `*.md` / `*/SKILL.md` globs.
|
|
20
|
+
class Installer
|
|
21
|
+
SOURCES_FILE = ".sources.json"
|
|
22
|
+
|
|
23
|
+
# GitHub shorthand: bare `owner/repo` (one slash, no scheme/host).
|
|
24
|
+
GITHUB_SHORTHAND = %r{\A[\w.-]+/[\w.-]+\z}
|
|
25
|
+
|
|
26
|
+
attr_reader :skills_dir
|
|
27
|
+
|
|
28
|
+
def initialize(skills_dir: nil)
|
|
29
|
+
# The same resolved home the registry's "~/.rubino/skills" entry
|
|
30
|
+
# expands to (RUBINO_HOME → else ~/.rubino), so an install is
|
|
31
|
+
# discovered without any config change.
|
|
32
|
+
@skills_dir = skills_dir || File.join(Config::Loader.default_home_path, "skills")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `owner/repo` → the GitHub URL; anything else is passed to git verbatim.
|
|
36
|
+
def self.url_for(source)
|
|
37
|
+
GITHUB_SHORTHAND.match?(source.to_s) ? "https://github.com/#{source}" : source.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Shallow-clones +source+ and yields (checkout_dir, head_sha); the tmp
|
|
41
|
+
# checkout is deleted when the block returns. Returns the block's value,
|
|
42
|
+
# or nil when the clone fails (unknown repo, no network — git's own
|
|
43
|
+
# stderr is left visible as the diagnostic). The ONE network touchpoint,
|
|
44
|
+
# so specs stub this method and never shell out.
|
|
45
|
+
def fetch(source)
|
|
46
|
+
Dir.mktmpdir("rubino-skills") do |dir|
|
|
47
|
+
return nil unless system("git", "clone", "--depth", "1", "--quiet",
|
|
48
|
+
self.class.url_for(source), dir, out: File::NULL)
|
|
49
|
+
|
|
50
|
+
sha = IO.popen(["git", "-C", dir, "rev-parse", "HEAD"], &:read).strip
|
|
51
|
+
yield dir, sha
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Skills discoverable in a checkout, as `{name:, path:, description:}`
|
|
56
|
+
# hashes (path = skill dir relative to the repo root). Recursive
|
|
57
|
+
# (`**/` + the registry's DIR_GLOB) so catalog repos that nest skills
|
|
58
|
+
# under a grouping dir are found too.
|
|
59
|
+
def discover(checkout)
|
|
60
|
+
Dir.glob(File.join("**", Registry::DIR_GLOB), base: checkout).sort.map do |rel|
|
|
61
|
+
dir = File.dirname(rel)
|
|
62
|
+
skill = Skill.new(path: File.join(checkout, rel))
|
|
63
|
+
{ name: skill.name, path: dir, description: skill.description.to_s }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Copies the discover-entries into the skills dir (replacing any prior
|
|
68
|
+
# copy of the same name) and records their provenance.
|
|
69
|
+
def install(entries, checkout:, source:, commit:)
|
|
70
|
+
FileUtils.mkdir_p(@skills_dir)
|
|
71
|
+
data = sources
|
|
72
|
+
entries.each do |entry|
|
|
73
|
+
dest = File.join(@skills_dir, entry[:name])
|
|
74
|
+
FileUtils.rm_rf(dest)
|
|
75
|
+
FileUtils.cp_r(File.join(checkout, entry[:path]), dest)
|
|
76
|
+
data[entry[:name]] = { "source" => source, "path" => entry[:path], "commit" => commit }
|
|
77
|
+
end
|
|
78
|
+
write_sources(data)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Re-fetches +names+ (default: every recorded skill) from their recorded
|
|
82
|
+
# sources, one clone per distinct source. Returns name → :updated /
|
|
83
|
+
# :up_to_date / :failed (clone failed, or the skill's recorded path no
|
|
84
|
+
# longer holds a SKILL.md) / :unknown (no provenance entry).
|
|
85
|
+
def update(names = [])
|
|
86
|
+
data = sources
|
|
87
|
+
names = data.keys if names.empty?
|
|
88
|
+
results = {}
|
|
89
|
+
names.group_by { |name| data.dig(name, "source") }.each do |source, group|
|
|
90
|
+
next group.each { |name| results[name] = :unknown } if source.nil?
|
|
91
|
+
|
|
92
|
+
fetched = fetch(source) do |checkout, sha|
|
|
93
|
+
group.each { |name| results[name] = update_one(name, data[name], checkout, sha) }
|
|
94
|
+
write_sources(data)
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
group.each { |name| results[name] = :failed } unless fetched
|
|
98
|
+
end
|
|
99
|
+
results
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Deletes the skill dir + provenance entry. Returns false (nothing
|
|
103
|
+
# touched) for a skill without a provenance entry — this mechanism only
|
|
104
|
+
# removes what it installed.
|
|
105
|
+
def remove(name) # rubocop:disable Naming/PredicateMethod -- "did I remove anything", a mutator reporting what it did
|
|
106
|
+
data = sources
|
|
107
|
+
return false unless data.key?(name)
|
|
108
|
+
|
|
109
|
+
FileUtils.rm_rf(File.join(@skills_dir, name))
|
|
110
|
+
data.delete(name)
|
|
111
|
+
write_sources(data)
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The provenance ledger (empty hash when absent or unparseable).
|
|
116
|
+
def sources
|
|
117
|
+
path = File.join(@skills_dir, SOURCES_FILE)
|
|
118
|
+
File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
{}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Re-copies one skill from a fresh checkout, mutating its ledger entry's
|
|
126
|
+
# commit in place (the caller persists the ledger once per source).
|
|
127
|
+
def update_one(name, entry, checkout, sha)
|
|
128
|
+
return :up_to_date if entry["commit"] == sha
|
|
129
|
+
|
|
130
|
+
src = File.join(checkout, entry["path"])
|
|
131
|
+
return :failed unless File.file?(File.join(src, "SKILL.md"))
|
|
132
|
+
|
|
133
|
+
dest = File.join(@skills_dir, name)
|
|
134
|
+
FileUtils.rm_rf(dest)
|
|
135
|
+
FileUtils.cp_r(src, dest)
|
|
136
|
+
entry["commit"] = sha
|
|
137
|
+
:updated
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def write_sources(data)
|
|
141
|
+
FileUtils.mkdir_p(@skills_dir)
|
|
142
|
+
File.write(File.join(@skills_dir, SOURCES_FILE), JSON.pretty_generate(data))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -11,6 +11,12 @@ module Rubino
|
|
|
11
11
|
# Directory skills: <dir>/<name>/SKILL.md (Claude skill layout).
|
|
12
12
|
DIR_GLOB = File.join("*", "SKILL.md")
|
|
13
13
|
|
|
14
|
+
# Agent-neutral skill dirs (the emerging `npx skills` / Gemini CLI /
|
|
15
|
+
# goose convention): discovered in ADDITION to the configured rubino
|
|
16
|
+
# paths, scanned before them so a rubino-path skill of the same name
|
|
17
|
+
# wins (lowest precedence). No behavior change when the dirs are absent.
|
|
18
|
+
AGENT_NEUTRAL_PATHS = [".agents/skills", "~/.agents/skills"].freeze
|
|
19
|
+
|
|
14
20
|
# Skills shipped *inside the gem* (skills/<name>/SKILL.md at the gem
|
|
15
21
|
# root, packaged via the gemspec's git-ls-files list). These are
|
|
16
22
|
# ALWAYS discovered — they don't depend on the user's skills.paths
|
|
@@ -176,6 +182,10 @@ module Rubino
|
|
|
176
182
|
".rubino/skills",
|
|
177
183
|
"~/.rubino/skills"
|
|
178
184
|
]
|
|
185
|
+
# Prepended (not appended) so the rubino paths override them on a name
|
|
186
|
+
# collision, and BEFORE the trust filter so the project-local
|
|
187
|
+
# `.agents/skills` is gated exactly like `.rubino/skills`.
|
|
188
|
+
paths = AGENT_NEUTRAL_PATHS + paths
|
|
179
189
|
unless @include_project_local
|
|
180
190
|
# Untrusted primary root: drop the project-local (cwd-relative) skill
|
|
181
191
|
# dirs, keeping only absolute / home (~) paths the user controls.
|
data/lib/rubino/skills/skill.rb
CHANGED
|
@@ -61,7 +61,7 @@ module Rubino
|
|
|
61
61
|
resolved = resolve_within_dir(relative_path)
|
|
62
62
|
return nil unless resolved && File.file?(resolved)
|
|
63
63
|
|
|
64
|
-
File.read(resolved)
|
|
64
|
+
File.read(resolved, encoding: "UTF-8")
|
|
65
65
|
rescue Errno::ENOENT, Errno::EACCES
|
|
66
66
|
nil
|
|
67
67
|
end
|
|
@@ -128,7 +128,7 @@ module Rubino
|
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
def parse_frontmatter!
|
|
131
|
-
raw = File.read(@path)
|
|
131
|
+
raw = File.read(@path, encoding: "UTF-8")
|
|
132
132
|
|
|
133
133
|
if raw.start_with?("---")
|
|
134
134
|
parts = raw.split("---", 3)
|
|
@@ -160,7 +160,7 @@ module Rubino
|
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
def load_content
|
|
163
|
-
raw = File.read(@path)
|
|
163
|
+
raw = File.read(@path, encoding: "UTF-8")
|
|
164
164
|
|
|
165
165
|
if raw.start_with?("---")
|
|
166
166
|
parts = raw.split("---", 3)
|
|
@@ -38,8 +38,11 @@ module Rubino
|
|
|
38
38
|
# #record_tool_started / #record_tool_finished) under the registry mutex
|
|
39
39
|
# and read by the parent renderer (UI::SubagentCards) and
|
|
40
40
|
# the /agents drill-in. activity_log is a bounded ring of the last few
|
|
41
|
-
# `✓ verb · hint` lines for the live drill-in;
|
|
42
|
-
#
|
|
41
|
+
# `✓ verb · hint` lines for the live drill-in; output_tail is the bounded
|
|
42
|
+
# line buffer of the CURRENTLY RUNNING tool's streamed output (fed by
|
|
43
|
+
# #record_tool_output, wiped at #record_tool_finished) that the drill-in's
|
|
44
|
+
# output: block tails (#5). Nothing is persisted (it dies with the
|
|
45
|
+
# process, like the rest of the registry).
|
|
43
46
|
#
|
|
44
47
|
# approval_gate / approval_question / approval_command are the
|
|
45
48
|
# Option-2 approval-surfacing state: when a background child's tool needs
|
|
@@ -49,7 +52,7 @@ module Rubino
|
|
|
49
52
|
Entry = Struct.new(
|
|
50
53
|
:id, :subagent, :prompt, :status, :result, :error,
|
|
51
54
|
:thread, :runner, :started_at, :finished_at,
|
|
52
|
-
:last_activity, :tool_count, :activity_log,
|
|
55
|
+
:last_activity, :tool_count, :activity_log, :output_tail,
|
|
53
56
|
:approval_gate, :approval_id, :approval_question, :approval_command,
|
|
54
57
|
# Parent->child steer (the `/agents <id> steer "..."` note). Wired into
|
|
55
58
|
# the child Loop as its Interaction::InputQueue (the SAME turn-boundary
|
|
@@ -94,6 +97,13 @@ module Rubino
|
|
|
94
97
|
# How many recent activity lines the drill-in shows (the live `recent:` ring).
|
|
95
98
|
ACTIVITY_LOG_MAX = 6
|
|
96
99
|
|
|
100
|
+
# Bounds for the live output tail (#5): how many COMPLETE lines the
|
|
101
|
+
# drill-in's output: block shows (the buffer keeps one extra slot for the
|
|
102
|
+
# in-flight partial line), and the byte cap per buffered line so a
|
|
103
|
+
# newline-free stream can't grow a line unbounded.
|
|
104
|
+
OUTPUT_TAIL_MAX = 6
|
|
105
|
+
OUTPUT_TAIL_LINE_MAX = 200
|
|
106
|
+
|
|
97
107
|
class << self
|
|
98
108
|
def instance
|
|
99
109
|
@instance ||= new
|
|
@@ -219,7 +229,8 @@ module Rubino
|
|
|
219
229
|
# Records a child tool FINISHING: appends a terse line to the bounded
|
|
220
230
|
# activity ring the live drill-in (#71) tails. Keeps the last
|
|
221
231
|
# ACTIVITY_LOG_MAX entries so the ring never grows unbounded for a
|
|
222
|
-
# read-heavy child.
|
|
232
|
+
# read-heavy child. Also wipes the live output tail — it belongs to the
|
|
233
|
+
# tool that just finished, so the drill-in's output: block clears (#5).
|
|
223
234
|
def record_tool_finished(id, line)
|
|
224
235
|
@mutex.synchronize do
|
|
225
236
|
entry = @entries[id]
|
|
@@ -228,6 +239,26 @@ module Rubino
|
|
|
228
239
|
log = (entry.activity_log ||= [])
|
|
229
240
|
log << line.to_s
|
|
230
241
|
log.shift while log.size > ACTIVITY_LOG_MAX
|
|
242
|
+
entry.output_tail = nil
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Records a streamed chunk of the CURRENTLY RUNNING tool's output (#5):
|
|
247
|
+
# splits on newlines into a bounded line buffer whose LAST slot carries
|
|
248
|
+
# the in-flight partial line, so the /agents drill-in can tail it live.
|
|
249
|
+
# Called from UI::SubagentView#tool_chunk on the CHILD thread, so it MUST
|
|
250
|
+
# take the mutex like the other record_* writers. No-op for an unknown id.
|
|
251
|
+
def record_tool_output(id, chunk)
|
|
252
|
+
@mutex.synchronize do
|
|
253
|
+
entry = @entries[id]
|
|
254
|
+
return unless entry
|
|
255
|
+
|
|
256
|
+
tail = (entry.output_tail ||= [""])
|
|
257
|
+
chunk.to_s.each_line do |line|
|
|
258
|
+
tail[-1] = "#{tail[-1]}#{line.chomp}"[0, OUTPUT_TAIL_LINE_MAX]
|
|
259
|
+
tail << "" if line.end_with?("\n")
|
|
260
|
+
end
|
|
261
|
+
tail.shift while tail.size > OUTPUT_TAIL_MAX + 1
|
|
231
262
|
end
|
|
232
263
|
end
|
|
233
264
|
|
|
@@ -28,7 +28,8 @@ module Rubino
|
|
|
28
28
|
# single in-place line per subagent (`▸ sa_… · explore · running · N tools ·
|
|
29
29
|
# Ns · <last_activity>`) that updates without scrolling — see UI::CLI
|
|
30
30
|
# #set_subagent_cards / UI::SubagentCards. The /agents <id> drill-in tails the
|
|
31
|
-
# same registry ring for the live recent: list (#71)
|
|
31
|
+
# same registry ring for the live recent: list (#71) and the entry's
|
|
32
|
+
# output_tail — fed by #tool_chunk — for the live output: block (#5).
|
|
32
33
|
#
|
|
33
34
|
# The view is wired with the entry id at construction (TaskTool builds it per
|
|
34
35
|
# background run). With no id (legacy/foreground synchronous path, tests) it
|
|
@@ -124,10 +125,20 @@ module Rubino
|
|
|
124
125
|
end
|
|
125
126
|
end
|
|
126
127
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
128
|
+
# Card mode: append the streamed chunk to the entry's bounded output tail —
|
|
129
|
+
# the live output: block the /agents <id> watch tails while THIS tool runs
|
|
130
|
+
# (#5). Registry-only, NO card repaint: a chatty shell streams a chunk per
|
|
131
|
+
# line and repainting the cards per chunk would flood the parent terminal
|
|
132
|
+
# (the watch drill-in re-reads the tail on its own tick). Legacy mode
|
|
133
|
+
# stays quiet (the start/finish rows already say what ran).
|
|
134
|
+
def tool_chunk(_name, chunk)
|
|
135
|
+
Tools::BackgroundTasks.instance.record_tool_output(@entry_id, chunk) if card_mode?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# tool_body: the END-OF-CALL preview of a NON-streaming tool (the executor
|
|
139
|
+
# skips it when the tool streamed via #tool_chunk). Useless for the live
|
|
140
|
+
# tail — tool_finished wipes the buffer right after — so it stays quiet.
|
|
129
141
|
def tool_body(_text, kind: :plain); end
|
|
130
|
-
def tool_chunk(_name, _chunk); end
|
|
131
142
|
|
|
132
143
|
# --- Suppressed: the child's prose / token stream ---------------------
|
|
133
144
|
|
data/lib/rubino/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubino-agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jhon Rojas
|
|
@@ -733,6 +733,7 @@ files:
|
|
|
733
733
|
- lib/rubino/session/repository.rb
|
|
734
734
|
- lib/rubino/session/store.rb
|
|
735
735
|
- lib/rubino/session/summary_store.rb
|
|
736
|
+
- lib/rubino/skills/installer.rb
|
|
736
737
|
- lib/rubino/skills/prompt_index.rb
|
|
737
738
|
- lib/rubino/skills/registry.rb
|
|
738
739
|
- lib/rubino/skills/skill.rb
|