samagotchi 0.35.0 → 0.37.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 +57 -1
- data/README.md +2 -2
- data/bin/chi +1 -1
- data/docs/broadcast.md +91 -12
- data/docs/configuration.md +8 -1
- data/docs/context.md +37 -0
- data/docs/guardrails.md +26 -3
- data/docs/hooks.md +1 -1
- data/docs/memory.md +3 -0
- data/docs/plugins.md +51 -4
- data/lib/samagotchi/broadcast/triage.rb +267 -0
- data/lib/samagotchi/broadcast/triage_log.rb +165 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +273 -69
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/engine.rb +4 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +19 -6
- data/lib/samagotchi/guardrails/targets.rb +83 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +5 -1
|
@@ -3,6 +3,43 @@
|
|
|
3
3
|
Use when the user hands over work with several independent parts, or asks to "run it in parallel",
|
|
4
4
|
"delegate", "coordinate". The user decides; you do the legwork and keep them in the loop briefly.
|
|
5
5
|
|
|
6
|
+
## Handoff memory
|
|
7
|
+
Your conversation is lost when this session ends; git and the session list are not. Keep a handoff in a
|
|
8
|
+
project-scope memory named `handoff_<epic-slug>` (the epic or goal in a few lowercase words joined by
|
|
9
|
+
`-`, e.g. `handoff_calc-v2`). Create it with memory_write (scope: project). Its description is the status:
|
|
10
|
+
one line under 200 characters that names its owner and says where things stand, never what to do (every
|
|
11
|
+
session in this repo sees it, children too): "OPEN coordinator handoff calc-v2 (session <your id>): 2/3
|
|
12
|
+
merged, docs kept (user)", later "DONE: …". Change the body with `edit` on the memory's file (in the
|
|
13
|
+
Project memories folder), and after every change of state change the status with memory_write name,
|
|
14
|
+
scope and description only (no content). It holds ONLY what git and the session list can't rebuild:
|
|
15
|
+
- Goal: the goal in one line and the split (task → branch, worktree, child session id).
|
|
16
|
+
- Decisions: what the user said per branch (merge / keep / drop / "don't touch X"), their words short.
|
|
17
|
+
- Verdicts: your verdict per report (verified / doubts / failed) and why, in one line.
|
|
18
|
+
- Follow-ups: found but not fixed.
|
|
19
|
+
Not commit lists, diffs, test output or anything a git command shows again. Update it right after:
|
|
20
|
+
the split started (step 3), each verdict (step 4), each user decision (an ask_user_question answer or a
|
|
21
|
+
plain message: save it before you act on it), each merge or cleanup (steps 6, 7). Before you end a turn
|
|
22
|
+
in which any of these changed, check the memory and its description have it. Short: under 40 lines.
|
|
23
|
+
One handoff per epic: never write another epic's handoff_* memory.
|
|
24
|
+
|
|
25
|
+
## Resume (step 0)
|
|
26
|
+
Before step 1, look at the project memory index for `handoff_*` entries not marked DONE (`/coordinate
|
|
27
|
+
resume` picks one for the user). If one is open and the user's request continues it, or doesn't name new
|
|
28
|
+
work ("hi", "what's next?", "where were we?"), do all of this before you answer:
|
|
29
|
+
1. Read this skill if you haven't in this session, and memory_read the handoff: its body is the state,
|
|
30
|
+
not the index line (that line is from when this session started). Then rebuild the truth:
|
|
31
|
+
`git worktree list`, `git branch --list`, for each branch in it `git log --oneline <default>..<branch>`
|
|
32
|
+
and `git merge-base --is-ancestor <branch> <default>`, and the list_sessions tool for the child ids
|
|
33
|
+
on record (not listed is not proof a child is gone).
|
|
34
|
+
2. Git and the sessions are the truth; the memory is your notes. Tell the user, per branch: its state
|
|
35
|
+
now, the decision and verdict on record, and every mismatch (merged by hand, worktree gone, branch
|
|
36
|
+
deleted, a child that ended without a verdict on record), in one short list.
|
|
37
|
+
3. Update the memory and its description to match the truth (keep the decisions; mark the mismatches;
|
|
38
|
+
the owner is now you), then go on from the first open step. Don't redo a merge or cleanup git shows
|
|
39
|
+
done.
|
|
40
|
+
If the request is a new goal, say which handoffs are open (one line each) and go on with a new
|
|
41
|
+
`handoff_<slug>` of its own; don't touch the others.
|
|
42
|
+
|
|
6
43
|
## Steps
|
|
7
44
|
1. Find the repository's default branch, <default> below: `git symbolic-ref --short
|
|
8
45
|
refs/remotes/origin/HEAD` (drop the `origin/`), else `main` or `master`, whichever exists; ask the
|
|
@@ -13,34 +50,43 @@ Use when the user hands over work with several independent parts, or asks to "ru
|
|
|
13
50
|
self-contained task. The task starts with "Work only in <absolute worktree path>: every cd, read,
|
|
14
51
|
write, test and commit goes there, never the project root" and says what to report (the result,
|
|
15
52
|
evidence: commands and what they printed, what isn't done) and "commit on the branch, don't merge,
|
|
16
|
-
don't push".
|
|
53
|
+
don't push, don't write or edit any handoff_* memory".
|
|
17
54
|
3. Tell the user what started (one line per child: its id, branch and task) and end your turn or keep
|
|
18
55
|
talking. Don't call delegate_result: chi brings each child's reply as a delegate report, also when
|
|
19
56
|
you are idle.
|
|
20
57
|
4. On each report, check it before telling the user: `git -C <worktree> log --oneline <default>..` and
|
|
21
58
|
`git diff --stat <default>...<branch>`; run the tests the child names (execute with cwd: the worktree).
|
|
22
|
-
Say the result in one or two lines: done and verified / done with doubts / failed.
|
|
23
|
-
|
|
24
|
-
|
|
59
|
+
Say the result in one or two lines: done and verified / done with doubts / failed. In the same turn,
|
|
60
|
+
write that verdict into the handoff body and change its description to match (description only).
|
|
61
|
+
5. Then one of (a user's decision about it goes into the handoff first): a follow-up (`delegate
|
|
62
|
+
session: <id>`, narrower, `wait: false`; then end your turn: its report comes by itself, so don't
|
|
63
|
+
wait for it with delegate_result or task_wait), stop it
|
|
25
64
|
(`chi sessions stop <session id>` with execute; task_stop is for task ids, not children; the user
|
|
26
65
|
can use /children), or tell the user it is ready to merge.
|
|
27
|
-
6. Merging is the user's call: ask (ask_user_question) per branch, "Merge <branch> into <default>?"
|
|
66
|
+
6. Merging is the user's call: ask (ask_user_question) per branch, "Merge <branch> into <default>?", and
|
|
67
|
+
save the answer in the handoff before you act on it.
|
|
28
68
|
Before merging, in your own folder: `git branch --show-current` must print <default> (if it
|
|
29
69
|
doesn't, stop and ask the user; don't switch branches yourself); then check <default>:
|
|
30
70
|
`git log --oneline -3 <default>` and `git merge-base --is-ancestor <branch> <default>`; if the branch is already in, say so and don't merge
|
|
31
71
|
again. Merge with `git merge --ff-only <branch>` yourself, in your own folder. If that fails, ask the
|
|
32
72
|
child to rebase its branch on <default> (a follow-up that says "rebase only, don't merge") or ask the
|
|
33
73
|
user; don't rebase it from here (the branch is checked out in the child's worktree). Children
|
|
34
|
-
never merge. Never push unless asked.
|
|
35
|
-
|
|
36
|
-
|
|
74
|
+
never merge. Never push unless asked. Don't tell the user a branch can fast-forward unless
|
|
75
|
+
`git merge-base --is-ancestor <default> <branch>` says so now (after each merge <default> moved);
|
|
76
|
+
after a child rebases, check it again as in step 4.
|
|
77
|
+
7. Clean up only a branch that was merged or that the user said to drop (that decision saved in the
|
|
78
|
+
handoff first): stop its child (`chi sessions stop <session id>`), then `git worktree remove
|
|
79
|
+
../<repo>-<task>`, then
|
|
37
80
|
`git branch -d <branch>` (in that order: a branch checked out in a worktree can't be deleted). If
|
|
38
|
-
`-d` refuses, say why to the user; never `-D` unless they say so.
|
|
39
|
-
|
|
81
|
+
`-d` refuses, say why to the user; never `-D` unless they say so. If `git worktree remove` refuses
|
|
82
|
+
over untracked or ignored files, show `git -C <worktree> status --short --ignored` and ask; never
|
|
83
|
+
`rm -rf`. Remove only worktrees and branches you created in this session. Every other branch stays as it is, its child, worktree and
|
|
40
84
|
branch too (the user said "keep it", or didn't say): leave them and tell the user they remain.
|
|
41
85
|
8. When all are done, report: what merged, what didn't and why, what you cleaned up and what remains,
|
|
42
86
|
follow-ups found but not fixed. Report only what the commands' output showed: a step you planned
|
|
43
|
-
but didn't run, or whose output you didn't see, is not done; say so.
|
|
87
|
+
but didn't run, or whose output you didn't see, is not done; say so. Then set the handoff's
|
|
88
|
+
description to "DONE: …" and ask the user whether to remove it (memory_write name, scope and
|
|
89
|
+
remove: true).
|
|
44
90
|
|
|
45
91
|
## Gotchas
|
|
46
92
|
- Children are asked before changing anything outside their worktree (your checkout, a sibling's):
|
|
@@ -58,3 +104,5 @@ Use when the user hands over work with several independent parts, or asks to "ru
|
|
|
58
104
|
## Changelog
|
|
59
105
|
- 2026-10-06 created (coordinator bundle 0.1.0)
|
|
60
106
|
- 2026-10-06 clean up only merged or dropped branches; report only what commands showed (0.1.2)
|
|
107
|
+
- 2026-10-07 handoff memory with its status in the description, resume (step 0, /coordinate resume),
|
|
108
|
+
save decisions first, fast-forward and worktree-remove checks, remove the handoff when done (0.2.0)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: github-pr
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.2.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change
|
|
5
|
+
description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
requires_chi: ">= 0.32.0"
|
|
8
8
|
needs:
|
|
@@ -11,7 +11,7 @@ needs:
|
|
|
11
11
|
hint: brew install gh, then gh auth login
|
|
12
12
|
plugin:
|
|
13
13
|
file: plugin.rb
|
|
14
|
-
sha256: sha256:
|
|
14
|
+
sha256: sha256:f8b9794f84681f3a16a4d49da3b857a59fec3ccdc2d0a2b16d1192fd2a26bdf3
|
|
15
15
|
scripts:
|
|
16
16
|
pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
|
|
17
17
|
context_providers:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
require "open3"
|
|
6
|
+
require "tmpdir"
|
|
5
7
|
|
|
6
8
|
# github-pr: a session on a branch with an open GitHub PR gets the PR
|
|
7
9
|
# attached as context (docs/context.md) when its worker starts: a quiet
|
|
@@ -10,12 +12,46 @@ require "open3"
|
|
|
10
12
|
# serves `chi context add <PR URL>` and the web's "+ URL"). Nothing for a
|
|
11
13
|
# scratch session or a delegate child (D12), and nothing, quietly, without
|
|
12
14
|
# gh, its login, a repo or an open PR.
|
|
15
|
+
#
|
|
16
|
+
# Line links: in the web, a `lib/foo.rb:28` (or `lib/foo.rb:28-34`, in
|
|
17
|
+
# inline code too) in the model's answer links to that line of the PR the
|
|
18
|
+
# session reviews: the PR's Files changed view when the lines are in a
|
|
19
|
+
# hunk, else the file at the PR's head. The PRs are the session's attached
|
|
20
|
+
# github-pr sources and the PR URLs in the user's messages (a delegate
|
|
21
|
+
# child's task is its first one). Display only (event[:present]): the
|
|
22
|
+
# model's text stays as it was. The after_turn hook links from a per-PR
|
|
23
|
+
# cache and never runs gh (the web holds the answer until the hooks are
|
|
24
|
+
# done); before_turn, before_generation and the init task fill the cache
|
|
25
|
+
# off the turn's thread. `bundles: github-pr: line_links: false` turns it
|
|
26
|
+
# off.
|
|
13
27
|
class Plugin
|
|
14
28
|
# gh and git each get this long (a network hang must not keep a thread).
|
|
15
29
|
COMMAND_SECONDS = 20
|
|
30
|
+
# before_generation looks for a newly attached PR at most this often.
|
|
31
|
+
GENERATION_CHECK_SECONDS = 30
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@cache = PrCache.new
|
|
35
|
+
@generation_checked_at = nil
|
|
36
|
+
end
|
|
16
37
|
|
|
17
38
|
def register(chi)
|
|
18
|
-
chi.init("Looking for this branch's pull request", quiet: true)
|
|
39
|
+
chi.init("Looking for this branch's pull request", quiet: true) do |ctx|
|
|
40
|
+
summary = attach_branch_pr(ctx)
|
|
41
|
+
# Inline (an init task has its own thread), whatever the attach
|
|
42
|
+
# returned (attached already, a child, a scratch session), and after
|
|
43
|
+
# it, so a PR it just attached is warm for the first answer.
|
|
44
|
+
refresh(ctx, attached_prs(ctx)) if line_links?(ctx)
|
|
45
|
+
summary
|
|
46
|
+
end
|
|
47
|
+
chi.on(:before_turn) do |event, ctx|
|
|
48
|
+
# The prompt too: messages is the history before this turn, and a
|
|
49
|
+
# delegate child's task is its first prompt.
|
|
50
|
+
kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
|
|
51
|
+
end
|
|
52
|
+
chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
|
|
53
|
+
# After source-links (90): its links are in the display this one gets.
|
|
54
|
+
chi.on(:after_turn, priority: 100) { |event, ctx| link_answer(event, ctx) }
|
|
19
55
|
end
|
|
20
56
|
|
|
21
57
|
# @return [String] what it did (the init task's summary)
|
|
@@ -41,11 +77,102 @@ class Plugin
|
|
|
41
77
|
"no pull request attached"
|
|
42
78
|
end
|
|
43
79
|
|
|
80
|
+
# The after_turn handler: links the answer's `path:line` refs from the
|
|
81
|
+
# PRs' cached data. Never runs gh: nothing cached yet links nothing.
|
|
82
|
+
def link_answer(event, ctx)
|
|
83
|
+
return unless line_links?(ctx) && event[:status].to_s == "completed" && event[:present]
|
|
84
|
+
|
|
85
|
+
text = LineLinker.last_model_text(event[:messages])
|
|
86
|
+
return if text.nil? || !text.match?(LineLinker::QUICK)
|
|
87
|
+
|
|
88
|
+
prs = (attached_prs(ctx) + PrRef.from_messages(event[:messages])).uniq
|
|
89
|
+
cached = prs.filter_map { |pr| (data = @cache[pr]) && [pr, data] }
|
|
90
|
+
return if cached.empty?
|
|
91
|
+
|
|
92
|
+
event[:present].call { |display| LineLinker.new(cached).link(display) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Fetches the PRs in +prs+ whose cache entry is missing or old, on this
|
|
96
|
+
# thread, unless a refresh runs already (the init task; #kick's thread).
|
|
97
|
+
def refresh(ctx, prs)
|
|
98
|
+
stale = @cache.stale(prs.uniq, now)
|
|
99
|
+
return if stale.empty? || !@cache.claim
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
stale.each { |pr| fetch(ctx, pr) }
|
|
103
|
+
ensure
|
|
104
|
+
@cache.release
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
44
108
|
private
|
|
45
109
|
|
|
110
|
+
def line_links?(ctx) = ctx.settings["line_links"] != false
|
|
111
|
+
|
|
112
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
|
|
114
|
+
# #refresh on its own thread (a raw one, docs/plugins.md), when there is
|
|
115
|
+
# something to fetch and no refresh runs. Process exit ends it; a gh
|
|
116
|
+
# still running then finishes alone.
|
|
117
|
+
def kick(ctx, prs)
|
|
118
|
+
prs = prs.uniq
|
|
119
|
+
return if prs.empty? || @cache.refreshing? || @cache.stale(prs, now).empty?
|
|
120
|
+
|
|
121
|
+
thread = Thread.new { refresh(ctx, prs) }
|
|
122
|
+
thread.report_on_exception = false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A PR attached while the turn runs (a parent attaching it right after
|
|
126
|
+
# `delegate wait: false`), checked at most every GENERATION_CHECK_SECONDS.
|
|
127
|
+
def check_attached(ctx)
|
|
128
|
+
return unless line_links?(ctx)
|
|
129
|
+
return if @generation_checked_at && now - @generation_checked_at < GENERATION_CHECK_SECONDS
|
|
130
|
+
|
|
131
|
+
@generation_checked_at = now
|
|
132
|
+
kick(ctx, attached_prs(ctx))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The session's attached github-pr sources, as PrRefs.
|
|
136
|
+
def attached_prs(ctx)
|
|
137
|
+
ctx.context.list.filter_map { |source| PrRef.parse(source[:hint]) if source[:provider] == "github-pr" }
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
ctx.log.debug(:pr_lines_no_sources, error: e.class.name)
|
|
140
|
+
[]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The PR's head and base, and its files when the head moved (or none
|
|
144
|
+
# are cached). A failure is remembered, so the next kicks wait.
|
|
145
|
+
def fetch(ctx, pr)
|
|
146
|
+
shas = run(ctx, "gh", "api", "repos/#{pr.owner}/#{pr.repo}/pulls/#{pr.number}", "--jq",
|
|
147
|
+
'.head.sha + " " + .base.sha', chdir: Dir.tmpdir).to_s.split
|
|
148
|
+
return failed(ctx, pr, "no pull request data") unless shas.size == 2
|
|
149
|
+
|
|
150
|
+
cached = @cache[pr]
|
|
151
|
+
files = cached.files if cached && cached.head_sha == shas[0]
|
|
152
|
+
files ||= fetch_files(ctx, pr) or return failed(ctx, pr, "no file list")
|
|
153
|
+
@cache.store(pr, PrData.new(head_sha: shas[0], base_sha: shas[1], files: files, fetched_at: now))
|
|
154
|
+
ctx.log.debug(:pr_lines_fetched, pr: pr.url, files: files.size)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
failed(ctx, pr, "#{e.class.name}: #{e.message.to_s[0, 200]}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The PR's files, one JSON object a line (--jq), every page.
|
|
160
|
+
def fetch_files(ctx, pr)
|
|
161
|
+
out = run(ctx, "gh", "api", "--paginate", "repos/#{pr.owner}/#{pr.repo}/pulls/#{pr.number}/files?per_page=100",
|
|
162
|
+
"--jq", ".[] | {filename, status, previous_filename, patch}", chdir: Dir.tmpdir) or return nil
|
|
163
|
+
out.each_line.filter_map { |line| PrFile.from_api(JSON.parse(line)) unless line.strip.empty? }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Logged once per PR until a fetch of it succeeds (no gh, no login, no
|
|
167
|
+
# network: every kick would say it again).
|
|
168
|
+
def failed(ctx, pr, why)
|
|
169
|
+
ctx.log.info(:pr_lines_not_fetched, pr: pr.url, why: why) if @cache.fail(pr, now)
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
46
173
|
# stdout of a command that succeeded within COMMAND_SECONDS, else nil.
|
|
47
|
-
def run(ctx, *command)
|
|
48
|
-
Open3.popen2(*command, chdir:
|
|
174
|
+
def run(ctx, *command, chdir: ctx.cwd)
|
|
175
|
+
Open3.popen2(*command, chdir: chdir, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
|
|
49
176
|
stdin.close
|
|
50
177
|
reader = Thread.new { stdout.read }
|
|
51
178
|
unless wait.join(COMMAND_SECONDS)
|
|
@@ -63,3 +190,317 @@ class Plugin
|
|
|
63
190
|
nil
|
|
64
191
|
end
|
|
65
192
|
end
|
|
193
|
+
|
|
194
|
+
# A GitHub pull request, by its URL.
|
|
195
|
+
PrRef = Data.define(:owner, :repo, :number)
|
|
196
|
+
|
|
197
|
+
class PrRef
|
|
198
|
+
# A PR URL anywhere in a text (trailing text allowed, like the provider's
|
|
199
|
+
# match: `…/pull/42/files`).
|
|
200
|
+
URL = %r{https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)}
|
|
201
|
+
# PRs taken from the user's messages, newest first.
|
|
202
|
+
FROM_MESSAGES = 3
|
|
203
|
+
|
|
204
|
+
# @return [PrRef, nil]
|
|
205
|
+
def self.parse(text)
|
|
206
|
+
match = text.to_s.match(URL) or return nil
|
|
207
|
+
new(owner: match[1], repo: match[2], number: match[3].to_i)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The PRs the user's messages (and +prompt+, the turn's own) name, newest
|
|
211
|
+
# first, up to FROM_MESSAGES distinct. Not tool results or answers: `gh pr
|
|
212
|
+
# list` output, logs or a CHANGELOG name unrelated PRs.
|
|
213
|
+
def self.from_messages(messages, prompt: nil)
|
|
214
|
+
texts = Array(messages).filter_map do |message|
|
|
215
|
+
next unless message.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
role = message.key?(:role) ? message[:role] : message["role"]
|
|
218
|
+
(message.key?(:content) ? message[:content] : message["content"]).to_s if role.to_s == "user"
|
|
219
|
+
end
|
|
220
|
+
texts << prompt.to_s if prompt
|
|
221
|
+
found = []
|
|
222
|
+
texts.reverse_each do |text|
|
|
223
|
+
text.scan(URL).reverse_each do |owner, repo, number|
|
|
224
|
+
pr = new(owner: owner, repo: repo, number: number.to_i)
|
|
225
|
+
found << pr unless found.include?(pr)
|
|
226
|
+
return found if found.size >= FROM_MESSAGES
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
found
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def url = "https://github.com/#{owner}/#{repo}/pull/#{number}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# One file of a PR: its path (the new name), a rename's old one, its status
|
|
236
|
+
# ("added", "modified", "removed", "renamed", …) and its hunks as line
|
|
237
|
+
# ranges on each side (none: a binary file or too large a diff).
|
|
238
|
+
PrFile = Data.define(:path, :previous_path, :status, :new_hunks, :old_hunks)
|
|
239
|
+
|
|
240
|
+
class PrFile
|
|
241
|
+
# A hunk header: `@@ -a,b +c,d @@` (a count left out is 1).
|
|
242
|
+
HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
243
|
+
|
|
244
|
+
# @param file [Hash] one entry of the PR files API
|
|
245
|
+
# @return [PrFile, nil]
|
|
246
|
+
def self.from_api(file)
|
|
247
|
+
return nil unless file.is_a?(Hash) && file["filename"].is_a?(String)
|
|
248
|
+
|
|
249
|
+
old_hunks = []
|
|
250
|
+
new_hunks = []
|
|
251
|
+
file["patch"].to_s.scan(HUNK) do |old_start, old_count, new_start, new_count|
|
|
252
|
+
old_hunks << span(old_start, old_count)
|
|
253
|
+
new_hunks << span(new_start, new_count)
|
|
254
|
+
end
|
|
255
|
+
new(path: file["filename"], previous_path: file["previous_filename"], status: file["status"].to_s,
|
|
256
|
+
new_hunks: new_hunks.compact, old_hunks: old_hunks.compact)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A hunk side's lines, or nil for none (`+0,0`: a removed file).
|
|
260
|
+
def self.span(start, count)
|
|
261
|
+
count = (count || 1).to_i
|
|
262
|
+
count.zero? ? nil : start.to_i..(start.to_i + count - 1)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def removed? = status == "removed"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# What the cache holds of a PR.
|
|
269
|
+
PrData = Data.define(:head_sha, :base_sha, :files, :fetched_at)
|
|
270
|
+
|
|
271
|
+
# The PRs' data, per PR, shared by the hooks' threads and the refresh's.
|
|
272
|
+
class PrCache
|
|
273
|
+
# An entry (or a failed fetch) younger than this isn't fetched again.
|
|
274
|
+
FRESH_SECONDS = 60
|
|
275
|
+
|
|
276
|
+
def initialize
|
|
277
|
+
@mutex = Mutex.new
|
|
278
|
+
@entries = {}
|
|
279
|
+
@failed = {}
|
|
280
|
+
@refreshing = false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @return [PrData, nil]
|
|
284
|
+
def [](pr) = @mutex.synchronize { @entries[pr] }
|
|
285
|
+
|
|
286
|
+
def store(pr, data)
|
|
287
|
+
@mutex.synchronize do
|
|
288
|
+
@entries[pr] = data
|
|
289
|
+
@failed.delete(pr)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Remembers a failed fetch; true the first time since the last success.
|
|
294
|
+
def fail(pr, time)
|
|
295
|
+
@mutex.synchronize do
|
|
296
|
+
first = !@failed.key?(pr)
|
|
297
|
+
@failed[pr] = time
|
|
298
|
+
first
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The PRs whose entry is missing or older than FRESH_SECONDS (a recent
|
|
303
|
+
# failure counts as fresh).
|
|
304
|
+
def stale(prs, time)
|
|
305
|
+
@mutex.synchronize do
|
|
306
|
+
prs.reject do |pr|
|
|
307
|
+
fetched = [@entries[pr]&.fetched_at, @failed[pr]].compact.max
|
|
308
|
+
fetched && time - fetched < FRESH_SECONDS
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Takes the one refresh slot: false when a refresh runs.
|
|
314
|
+
def claim
|
|
315
|
+
@mutex.synchronize do
|
|
316
|
+
next false if @refreshing
|
|
317
|
+
|
|
318
|
+
@refreshing = true
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def release = @mutex.synchronize { @refreshing = false }
|
|
323
|
+
|
|
324
|
+
def refreshing? = @mutex.synchronize { @refreshing }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Links an answer's `path:line` and `path:line-line` refs to the PRs'
|
|
328
|
+
# lines. A ref in inline code links when the code is the ref alone
|
|
329
|
+
# (`` `foo.rb:28` `` becomes ``[`foo.rb:28`](url)``); fenced blocks,
|
|
330
|
+
# markdown links and bare URLs are left alone.
|
|
331
|
+
class LineLinker
|
|
332
|
+
# The answer is scanned only up to this many characters.
|
|
333
|
+
MAX_SCAN = 20_000
|
|
334
|
+
# A path (no spaces), a line, an optional end line and an optional
|
|
335
|
+
# column (kept in the link's text, not used).
|
|
336
|
+
REF = %r{(?<![\w./@+~-])(?<path>[\w./@+~-]*[\w@+~-]):(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])}
|
|
337
|
+
REF_ALONE = /\A#{REF}\z/
|
|
338
|
+
# Whether an answer may hold a ref at all.
|
|
339
|
+
QUICK = /\S:\d/
|
|
340
|
+
URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
|
|
341
|
+
MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/
|
|
342
|
+
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
343
|
+
FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
|
|
344
|
+
|
|
345
|
+
# The content of the last model message, or nil (string or symbol keys).
|
|
346
|
+
def self.last_model_text(messages)
|
|
347
|
+
Array(messages).reverse_each do |message|
|
|
348
|
+
next unless message.is_a?(Hash)
|
|
349
|
+
next unless (message.key?(:role) ? message[:role] : message["role"]).to_s == "model"
|
|
350
|
+
|
|
351
|
+
return (message.key?(:content) ? message[:content] : message["content"]).to_s
|
|
352
|
+
end
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @param prs [Array<Array(PrRef, PrData)>]
|
|
357
|
+
def initialize(prs)
|
|
358
|
+
@prs = prs
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# +text+ with each ref that names exactly one PR's file as a markdown
|
|
362
|
+
# link; the part beyond MAX_SCAN stays as it is.
|
|
363
|
+
def link(text)
|
|
364
|
+
head = text[0, MAX_SCAN]
|
|
365
|
+
out = +""
|
|
366
|
+
pos = 0
|
|
367
|
+
hits(head).each do |start, finish, url|
|
|
368
|
+
out << head[pos...start] << "[#{head[start...finish]}](#{url})"
|
|
369
|
+
pos = finish
|
|
370
|
+
end
|
|
371
|
+
return text if pos.zero?
|
|
372
|
+
|
|
373
|
+
out << head[pos..] << text[MAX_SCAN..].to_s
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# The URL for +path+'s lines +from+..+to+ (+to+ nil: one line), or nil:
|
|
377
|
+
# no PR has the file, or more than one does.
|
|
378
|
+
def url_for(path, from, to = nil)
|
|
379
|
+
found = @prs.filter_map do |pr, data|
|
|
380
|
+
file = resolve(data.files, path)
|
|
381
|
+
[pr, data, file] if file
|
|
382
|
+
end
|
|
383
|
+
return nil unless found.size == 1
|
|
384
|
+
|
|
385
|
+
line_url(*found.first, from, to)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private
|
|
389
|
+
|
|
390
|
+
# [start, finish, url] for each ref to link, by offset.
|
|
391
|
+
def hits(text)
|
|
392
|
+
fences = fenced_blocks(text)
|
|
393
|
+
skipped = fences + spans(text, URL_SPAN) + spans(text, MARKDOWN_LINK)
|
|
394
|
+
code = inline_code(text, fences)
|
|
395
|
+
found = []
|
|
396
|
+
code.each do |start, finish, inner|
|
|
397
|
+
next if inside_any?(skipped, start, finish)
|
|
398
|
+
|
|
399
|
+
match = inner.strip.match(REF_ALONE) or next
|
|
400
|
+
url = ref_url(match) and found << [start, finish, url]
|
|
401
|
+
end
|
|
402
|
+
text.scan(REF) do
|
|
403
|
+
match = Regexp.last_match
|
|
404
|
+
start = match.begin(0)
|
|
405
|
+
finish = match.end(0)
|
|
406
|
+
next if inside_any?(skipped, start, finish) || inside_any?(code, start, finish)
|
|
407
|
+
|
|
408
|
+
url = ref_url(match) and found << [start, finish, url]
|
|
409
|
+
end
|
|
410
|
+
found.sort_by(&:first)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# A path has a `/` or an extension (`10:30` is a time, `host:8080` a port).
|
|
414
|
+
def ref_url(match)
|
|
415
|
+
path = match[:path]
|
|
416
|
+
return nil unless path.include?("/") || path.match?(/\.[A-Za-z0-9]+\z/)
|
|
417
|
+
|
|
418
|
+
from = match[:from].to_i
|
|
419
|
+
to = match[:to]&.to_i
|
|
420
|
+
to = nil if to && to <= from
|
|
421
|
+
from.zero? ? nil : url_for(path, from, to)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# The PR file +path+ names: the path (or a rename's old one) exactly,
|
|
425
|
+
# after a leading `./`; else the longest PR path +path+ ends with on a
|
|
426
|
+
# `/` boundary (an absolute path into a worktree); else the one PR path
|
|
427
|
+
# that ends with +path+ (a bare `foo.rb`). nil: none, or several.
|
|
428
|
+
def resolve(files, path)
|
|
429
|
+
path = path.sub(%r{\A(?:\./)+}, "")
|
|
430
|
+
exact = files.find { |file| file.path == path } || files.find { |file| file.previous_path == path }
|
|
431
|
+
return exact if exact
|
|
432
|
+
|
|
433
|
+
longer = files.select { |file| path.end_with?("/#{file.path}") }.max_by { |file| file.path.length }
|
|
434
|
+
return longer if longer
|
|
435
|
+
|
|
436
|
+
shorter = files.select { |file| file.path.end_with?("/#{path}") }
|
|
437
|
+
shorter.one? ? shorter.first : nil
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# The PR's Files changed anchor when the lines are inside one hunk (the
|
|
441
|
+
# new side; a removed file's old side), else the file at the PR's head
|
|
442
|
+
# (a removed one: at its base), which has every line.
|
|
443
|
+
def line_url(pr, data, file, from, to)
|
|
444
|
+
last = to || from
|
|
445
|
+
hunks, side = file.removed? ? [file.old_hunks, "L"] : [file.new_hunks, "R"]
|
|
446
|
+
if hunks.any? { |hunk| hunk.cover?(from) && hunk.cover?(last) }
|
|
447
|
+
anchor = "#{side}#{from}#{"-#{side}#{to}" if to}"
|
|
448
|
+
return "#{pr.url}/files#diff-#{Digest::SHA256.hexdigest(file.path)}#{anchor}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
sha = file.removed? ? data.base_sha : data.head_sha
|
|
452
|
+
path = file.path.split("/").map { |segment| segment.gsub(/[^\w.~-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join } }
|
|
453
|
+
"https://github.com/#{pr.owner}/#{pr.repo}/blob/#{sha}/#{path.join("/")}#L#{from}#{"-L#{to}" if to}"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# [start, finish, inner] for each inline code span outside +fences+: a
|
|
457
|
+
# run of backticks to the next run of the same length.
|
|
458
|
+
def inline_code(text, fences)
|
|
459
|
+
runs = []
|
|
460
|
+
text.scan(/`+/) { runs << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
461
|
+
runs.reject! { |start, finish| inside_any?(fences, start, finish) }
|
|
462
|
+
by_length = Hash.new { |hash, key| hash[key] = [] }
|
|
463
|
+
runs.each_with_index { |(start, finish), index| by_length[finish - start] << index }
|
|
464
|
+
found = []
|
|
465
|
+
index = 0
|
|
466
|
+
while index < runs.size
|
|
467
|
+
start, finish = runs[index]
|
|
468
|
+
closing = by_length[finish - start].bsearch { |other| other > index }
|
|
469
|
+
if closing
|
|
470
|
+
found << [start, runs[closing][1], text[finish...runs[closing][0]]]
|
|
471
|
+
index = closing + 1
|
|
472
|
+
else
|
|
473
|
+
index += 1
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
found
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def fenced_blocks(text)
|
|
480
|
+
blocks = []
|
|
481
|
+
open = nil
|
|
482
|
+
offset = 0
|
|
483
|
+
text.each_line do |line|
|
|
484
|
+
marker = line[FENCE_OPEN, 1]
|
|
485
|
+
if open.nil? && marker
|
|
486
|
+
open = [offset, marker]
|
|
487
|
+
elsif open && marker && marker[0] == open[1][0] && marker.length >= open[1].length && line.strip == marker
|
|
488
|
+
blocks << [open[0], offset + line.length]
|
|
489
|
+
open = nil
|
|
490
|
+
end
|
|
491
|
+
offset += line.length
|
|
492
|
+
end
|
|
493
|
+
blocks << [open[0], text.length] if open
|
|
494
|
+
blocks
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def spans(text, regex)
|
|
498
|
+
found = []
|
|
499
|
+
text.scan(regex) { found << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
500
|
+
found
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def inside_any?(spans, start, finish)
|
|
504
|
+
spans.any? { |span_start, span_end| start >= span_start && finish <= span_end }
|
|
505
|
+
end
|
|
506
|
+
end
|
|
@@ -60,6 +60,17 @@ rules:
|
|
|
60
60
|
verdict: ask
|
|
61
61
|
reason: rm -rf on a path outside the working tree
|
|
62
62
|
|
|
63
|
+
# memory_write remove: true moves a memory into the bundle trash. Memory
|
|
64
|
+
# files don't count as outside the repo, so nothing else asks; a delegate
|
|
65
|
+
# child could otherwise take its parent's handoff away. Every removal
|
|
66
|
+
# asks, once at a time.
|
|
67
|
+
- id: memory-remove
|
|
68
|
+
tool: memory_write
|
|
69
|
+
memory: remove
|
|
70
|
+
verdict: ask
|
|
71
|
+
scopes: [once]
|
|
72
|
+
reason: removes a memory (it goes to the bundle trash, which chi bundle trash lists)
|
|
73
|
+
|
|
63
74
|
# ── running code from the network ──────────────────────────────────────
|
|
64
75
|
- id: pipe-to-shell
|
|
65
76
|
tool: shell
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.8.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
5
|
+
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
|
|
8
|
-
#
|
|
9
|
-
|
|
8
|
+
# a rule's memory: key (memory-remove) needs 0.36.0: an older chi refuses the rule set (unknown key), so every
|
|
9
|
+
# guarded call would be refused
|
|
10
|
+
requires_chi: ">= 0.36.0"
|
|
10
11
|
files:
|
|
11
12
|
guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.5.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:e41b880d70d2574f2be7d64e3ddee209ec77ce203f77d347ccdc6f9df1b358e1
|
|
11
11
|
requires_chi: ">= 0.33.0"
|