samagotchi 0.32.0 → 0.34.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 +75 -1
- data/README.md +4 -1
- data/docs/context.md +8 -1
- data/docs/guardrails.md +31 -1
- data/docs/hooks.md +7 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +79 -3
- data/docs/releasing.md +3 -2
- data/docs/sessions.md +9 -3
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/child_reports.rb +31 -10
- data/lib/samagotchi/children_status.rb +157 -0
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/context_absorber.rb +2 -1
- data/lib/samagotchi/context_fetch.rb +14 -45
- data/lib/samagotchi/context_sources.rb +20 -10
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +24 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/memory_bundle/builder.rb +35 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +47 -2
- data/lib/samagotchi/process_group.rb +157 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/steer.rb +27 -19
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +20 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +105 -16
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +19 -20
- data/lib/samagotchi/web/public/data.js +3 -0
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/turn_events.js +9 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/worker.rb +40 -13
- metadata +14 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The coordinator bundle (docs/plugins.md, The coordinator bundle): chi as a
|
|
4
|
+
# coordinator of parallel work. Its skill_coordinator memory holds the steps
|
|
5
|
+
# (split the work, one worktree and a wait: false delegate per task, check
|
|
6
|
+
# each report, ask before each merge); this plugin adds two commands.
|
|
7
|
+
#
|
|
8
|
+
# /children [all] shows this session's children as a card, newest first:
|
|
9
|
+
# state, branch, the last reply and whether it was reported (ChildrenStatus
|
|
10
|
+
# through ctx.sessions.children), with a Stop button per running or waiting
|
|
11
|
+
# child and Refresh. `all` adds archived children. The card has one id, so
|
|
12
|
+
# Refresh and Stop replace it. It is read on demand: no thread, no polling.
|
|
13
|
+
#
|
|
14
|
+
# /children stop <id> stops one of this session's own children
|
|
15
|
+
# (ctx.sessions.stop, by session id) and shows the card again.
|
|
16
|
+
#
|
|
17
|
+
# /coordinate <goal> asks the model, in this session, to follow the skill
|
|
18
|
+
# for the goal. A REPL session (--no-shared) takes no messages: the request
|
|
19
|
+
# is shown for the user to send.
|
|
20
|
+
#
|
|
21
|
+
# A child is asked before it changes anything outside its worktree by chi
|
|
22
|
+
# itself (the core child-boundary guardrail), in every mode: nothing to set up.
|
|
23
|
+
class Plugin
|
|
24
|
+
CARD_ID = "children"
|
|
25
|
+
# A card takes 6 actions: Refresh and up to 5 Stops.
|
|
26
|
+
MAX_STOPS = 5
|
|
27
|
+
STOPPABLE = %w[running waiting].freeze
|
|
28
|
+
TEXT_CHARS = 80
|
|
29
|
+
USAGE = "usage: /children [all] | /children stop <id>"
|
|
30
|
+
COORDINATE_USAGE = "usage: /coordinate <goal> — chi splits it into tasks, one child session and worktree each"
|
|
31
|
+
|
|
32
|
+
def initialize(_settings = {}); end
|
|
33
|
+
|
|
34
|
+
def register(chi)
|
|
35
|
+
chi.command "/children", "this session's child sessions: state, branch, last reply; all, stop <id>",
|
|
36
|
+
anytime: true do |args, ctx|
|
|
37
|
+
children_command(args.to_s.strip, ctx)
|
|
38
|
+
end
|
|
39
|
+
chi.command "/coordinate", "run work in parallel: chi splits <goal> into tasks, each in a child session and worktree",
|
|
40
|
+
anytime: true do |args, ctx|
|
|
41
|
+
coordinate(args.to_s.strip, ctx)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def children_command(args, ctx)
|
|
48
|
+
verb, rest = args.split(/\s+/, 2)
|
|
49
|
+
case verb
|
|
50
|
+
when nil then show(ctx, all: false)
|
|
51
|
+
when "all" then rest.to_s.empty? ? show(ctx, all: true) : USAGE
|
|
52
|
+
when "stop" then rest.to_s.strip.empty? ? USAGE : stop(rest.strip, ctx)
|
|
53
|
+
else USAGE
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The card, made or replaced; nil, so the command adds no text of its own.
|
|
58
|
+
def show(ctx, all:, note: nil)
|
|
59
|
+
children = ctx.sessions.children(all: all)
|
|
60
|
+
title = "children of #{ctx.session_id.to_s[0, 8]}"
|
|
61
|
+
title += " (#{children.size})" unless children.empty?
|
|
62
|
+
body = children.empty? ? "no children" : children.map { |child| line(child) }.join("\n")
|
|
63
|
+
body = "#{note}\n\n#{body}" if note
|
|
64
|
+
refresh = all ? "/children all" : "/children"
|
|
65
|
+
stops = children.select { |child| STOPPABLE.include?(child[:state]) }.first(MAX_STOPS)
|
|
66
|
+
.map { |child| { label: "Stop #{child[:short_id]}", command: "/children stop #{child[:short_id]}" } }
|
|
67
|
+
ctx.card(id: CARD_ID, title: title, body: body, actions: [{ label: "Refresh", command: refresh }, *stops])
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# - `ab12cd34` · running · fix/flaky · "fix the flaky spec…"
|
|
72
|
+
# - `9a8b7c6d` · done · feat/x · reported · "All 12 specs pass…"
|
|
73
|
+
# - `77aa66bb` · waiting (approval) · open it: chi --attach 77aa66bb
|
|
74
|
+
def line(child)
|
|
75
|
+
parts = ["`#{child[:short_id]}`", child[:waiting] ? "waiting (#{child[:waiting]})" : child[:state]]
|
|
76
|
+
parts << child[:branch] if child[:branch]
|
|
77
|
+
parts << "fork" unless child[:delegate]
|
|
78
|
+
parts << "archived" if child[:archived]
|
|
79
|
+
if child[:waiting]
|
|
80
|
+
parts << "open it: chi --attach #{child[:short_id]}"
|
|
81
|
+
elsif child[:last_reply]
|
|
82
|
+
parts << (child[:reported] ? "reported" : "not reported yet")
|
|
83
|
+
parts << quote(child[:last_reply])
|
|
84
|
+
elsif !child[:title].to_s.strip.empty?
|
|
85
|
+
parts << quote(child[:title])
|
|
86
|
+
end
|
|
87
|
+
"- #{parts.join(" · ")}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stop(id, ctx)
|
|
91
|
+
stopped = ctx.sessions.stop(id)
|
|
92
|
+
show(ctx, all: false, note: "stopped #{stopped[0, 8]}")
|
|
93
|
+
rescue Samagotchi::Plugin::Sessions::Error => e
|
|
94
|
+
"/children stop: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def coordinate(goal, ctx)
|
|
98
|
+
return COORDINATE_USAGE if goal.empty?
|
|
99
|
+
|
|
100
|
+
request = "Read the skill_coordinator memory and follow it for this goal:\n\n#{goal}"
|
|
101
|
+
begin
|
|
102
|
+
ctx.sessions.send(ctx.session_id, request)
|
|
103
|
+
rescue Samagotchi::Plugin::Sessions::Error => e
|
|
104
|
+
return "/coordinate: #{e.message}. Send this yourself:\n\n#{request}"
|
|
105
|
+
end
|
|
106
|
+
"asked chi to coordinate it; a running turn gets it at its next step"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def quote(text)
|
|
110
|
+
line = text.to_s.gsub(/\s+/, " ").strip
|
|
111
|
+
line = "#{line[0, TEXT_CHARS - 1]}…" if line.length > TEXT_CHARS
|
|
112
|
+
"\"#{line}\""
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Skill: coordinator
|
|
2
|
+
|
|
3
|
+
Use when the user hands over work with several independent parts, or asks to "run it in parallel",
|
|
4
|
+
"delegate", "coordinate". The user decides; you do the legwork and keep them in the loop briefly.
|
|
5
|
+
|
|
6
|
+
## Steps
|
|
7
|
+
1. Find the repository's default branch, <default> below: `git symbolic-ref --short
|
|
8
|
+
refs/remotes/origin/HEAD` (drop the `origin/`), else `main` or `master`, whichever exists; ask the
|
|
9
|
+
user if unsure. Split the work into independent tasks (at most session.max_children at once,
|
|
10
|
+
default 4). Tell the user the split in a few lines and go on unless they object.
|
|
11
|
+
2. For each task: from your own folder, `git worktree add ../<repo>-<task> -b <type>/<task>` with
|
|
12
|
+
execute; then `delegate` with `wait: false`, `cwd:` that worktree's absolute path, and a
|
|
13
|
+
self-contained task. The task starts with "Work only in <absolute worktree path>: every cd, read,
|
|
14
|
+
write, test and commit goes there, never the project root" and says what to report (the result,
|
|
15
|
+
evidence: commands and what they printed, what isn't done) and "commit on the branch, don't merge,
|
|
16
|
+
don't push".
|
|
17
|
+
3. Tell the user what started (one line per child: its id, branch and task) and end your turn or keep
|
|
18
|
+
talking. Don't call delegate_result: chi brings each child's reply as a delegate report, also when
|
|
19
|
+
you are idle.
|
|
20
|
+
4. On each report, check it before telling the user: `git -C <worktree> log --oneline <default>..` and
|
|
21
|
+
`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
|
+
5. Then one of: a follow-up (`delegate session: <id>`, narrower, `wait: false`; then end your turn:
|
|
24
|
+
its report comes by itself, so don't wait for it with delegate_result or task_wait), stop it
|
|
25
|
+
(`chi sessions stop <session id>` with execute; task_stop is for task ids, not children; the user
|
|
26
|
+
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>?".
|
|
28
|
+
Before merging, in your own folder: `git branch --show-current` must print <default> (if it
|
|
29
|
+
doesn't, stop and ask the user; don't switch branches yourself); then check <default>:
|
|
30
|
+
`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
|
+
again. Merge with `git merge --ff-only <branch>` yourself, in your own folder. If that fails, ask the
|
|
32
|
+
child to rebase its branch on <default> (a follow-up that says "rebase only, don't merge") or ask the
|
|
33
|
+
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
|
+
7. Clean up only a branch that was merged or that the user said to drop: stop its child
|
|
36
|
+
(`chi sessions stop <session id>`), then `git worktree remove ../<repo>-<task>`, then
|
|
37
|
+
`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. Remove only worktrees and
|
|
39
|
+
branches you created in this session. Every other branch stays as it is, its child, worktree and
|
|
40
|
+
branch too (the user said "keep it", or didn't say): leave them and tell the user they remain.
|
|
41
|
+
8. When all are done, report: what merged, what didn't and why, what you cleaned up and what remains,
|
|
42
|
+
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.
|
|
44
|
+
|
|
45
|
+
## Gotchas
|
|
46
|
+
- Children are asked before changing anything outside their worktree (your checkout, a sibling's):
|
|
47
|
+
the user approves it on the child's card. Still check where commits landed (step 4).
|
|
48
|
+
- Never kill processes by a pid from a file or an old note; stop children with `chi sessions stop
|
|
49
|
+
<session id>` (execute) and background tasks with task_stop (task ids only: it doesn't stop a child).
|
|
50
|
+
- A child's tool approvals go to the user on the child's card; tell the user when a report says a
|
|
51
|
+
child waits for one.
|
|
52
|
+
- A report is the child's claim: verify it (step 4) before telling the user it is done, and check
|
|
53
|
+
where its commits landed: `git log --oneline <default>..<branch>` has them and `git status` in your own
|
|
54
|
+
folder is clean. A child that worked in your folder instead of its worktree: tell the user.
|
|
55
|
+
- Don't send a follow-up to a child that is still running; wait for its report.
|
|
56
|
+
- Don't start more children than the user asked for.
|
|
57
|
+
|
|
58
|
+
## Changelog
|
|
59
|
+
- 2026-10-06 created (coordinator bundle 0.1.0)
|
|
60
|
+
- 2026-10-06 clean up only merged or dropped branches; report only what commands showed (0.1.2)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dev
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.3.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Developer tools"
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
requires_chi: ">= 0.
|
|
7
|
+
requires_chi: ">= 0.34.0"
|
|
8
8
|
includes:
|
|
9
9
|
- known-names
|
|
10
10
|
- mcp
|
|
@@ -12,3 +12,4 @@ includes:
|
|
|
12
12
|
- skills
|
|
13
13
|
- source-links
|
|
14
14
|
- github-pr
|
|
15
|
+
- coordinator
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.4.
|
|
3
|
+
version: 0.4.5
|
|
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:
|
|
11
|
-
requires_chi: ">= 0.
|
|
10
|
+
sha256: sha256:1ebedc86cd21c918766c794015b6bd16c397c0f7e2734d939d8310d446b8a9a1
|
|
11
|
+
requires_chi: ">= 0.33.0"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
require "digest"
|
|
32
32
|
require "json"
|
|
33
33
|
require "open3"
|
|
34
|
+
require "samagotchi/process_group"
|
|
34
35
|
require "time"
|
|
35
36
|
require "shellwords"
|
|
36
37
|
require "tmpdir"
|
|
@@ -59,6 +60,9 @@ class Plugin
|
|
|
59
60
|
class Dead < Error; end
|
|
60
61
|
|
|
61
62
|
POLL_SECONDS = 0.1
|
|
63
|
+
# How long close waits for the server after closing its stdin, after
|
|
64
|
+
# TERM, and after KILL.
|
|
65
|
+
STOP_GRACE_SECONDS = 1
|
|
62
66
|
|
|
63
67
|
attr_reader :pid
|
|
64
68
|
|
|
@@ -136,14 +140,15 @@ class Plugin
|
|
|
136
140
|
end
|
|
137
141
|
|
|
138
142
|
# End the process: stdin closed first (most servers leave then), then
|
|
139
|
-
# TERM and KILL to its process group
|
|
143
|
+
# TERM and KILL to its process group (chi's ProcessGroup), a second's
|
|
144
|
+
# grace each.
|
|
140
145
|
def close
|
|
141
146
|
@mutex.synchronize { @closing = true }
|
|
142
147
|
[@stdin].each { |io| io.close unless io.closed? }
|
|
143
|
-
unless @wait.join(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
@wait.join(
|
|
148
|
+
unless @wait.join(STOP_GRACE_SECONDS)
|
|
149
|
+
Samagotchi::ProcessGroup.stop(@pid, grace: STOP_GRACE_SECONDS, poll: STOP_GRACE_SECONDS,
|
|
150
|
+
stopped: -> { !@wait.alive? }, wait: ->(seconds) { @wait.join(seconds) })
|
|
151
|
+
@wait.join(STOP_GRACE_SECONDS)
|
|
147
152
|
end
|
|
148
153
|
[@stdout, @stderr].each { |io| io.close unless io.closed? }
|
|
149
154
|
[@reader, @err_reader].each { |t| t.join(1) }
|
|
@@ -234,12 +239,6 @@ class Plugin
|
|
|
234
239
|
@on_exit&.call(reason) unless closing
|
|
235
240
|
end
|
|
236
241
|
|
|
237
|
-
def signal(name)
|
|
238
|
-
Process.kill(name, -@pid)
|
|
239
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
240
|
-
nil
|
|
241
|
-
end
|
|
242
|
-
|
|
243
242
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
244
243
|
end
|
|
245
244
|
|
|
@@ -5,6 +5,6 @@ Another chi session (the parent) gave you this task. It reads only your last mes
|
|
|
5
5
|
- Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
|
|
6
6
|
- Do only what the task asks. A question ("why does this spec fail?", "what calls X?", "check whether…") gets a report, not file changes: say what you found and what change you would make, and leave making it to the parent. Edit files only when the task asks you to change, fix, add or write something, and then only the files that takes.
|
|
7
7
|
- The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
|
|
8
|
-
- Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
|
|
8
|
+
- Work in the directory you were started in: every cd, read, edit, test and commit stays in it (in a linked worktree, never the repository's other checkouts). Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
|
|
9
9
|
- You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
|
|
10
10
|
- If you are blocked, stop and say so in the reply with the exact question. Use ask_user_question only when the task says the user is watching.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.34.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
8
|
+
self_map.md: sha256:eb017702746f9aad96404d9982a3c27bec717906fa2a14175ab207ad6f843f72
|
|
9
9
|
config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
|
|
10
|
-
memory_guide.md: sha256:
|
|
11
|
-
delegated.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
|
|
11
|
+
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -15,7 +15,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
|
|
|
15
15
|
| `system` | `$XDG_CONFIG_HOME/samagotchi/memories/`, default `~/.config/samagotchi/memories/` (`MemoryPaths.system_dir`) | User-wide preferences, identity, cross-project knowledge |
|
|
16
16
|
| `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
|
|
17
17
|
|
|
18
|
-
- The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the
|
|
18
|
+
- The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows that folder (not the root: in a linked worktree, work stays in the worktree). Outside a repository the root is the working directory. A separate clone is a different project.
|
|
19
19
|
- `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
|
|
20
20
|
|
|
21
21
|
## Tools you have
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
`chi send --new --wait -m TEXT` starts a session the user can watch in the web and prints its
|
|
31
31
|
answer (exit 3: it waits for the user's answer); see "Starting a session".
|
|
32
32
|
- `delegate` hands a task to a child session that runs in parallel and returns only its final
|
|
33
|
-
reply (`
|
|
34
|
-
as a delegate report when the child ends its turn
|
|
33
|
+
reply (`wait: false` is the usual way: I end my turn or keep talking with my user, and chi
|
|
34
|
+
brings the reply to me by itself as a delegate report when the child ends its turn, starting a
|
|
35
|
+
turn for it if I'm idle; `delegate_result` only when this turn can't go on without the reply;
|
|
36
|
+
`session:` sends a follow-up to a child). A child is a
|
|
35
37
|
normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
|
|
36
38
|
with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
|
|
37
39
|
approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "client_id"
|
|
4
5
|
require_relative "session"
|
|
5
6
|
require_relative "session_inbox"
|
|
6
7
|
require_relative "reply_wait"
|
|
@@ -29,6 +30,13 @@ module Samagotchi
|
|
|
29
30
|
# Turns an idle parent runs for reports in a row with no human input.
|
|
30
31
|
MAX_WAKES_KEY = "session.max_wakes"
|
|
31
32
|
MAX_WAKES_DEFAULT = 10
|
|
33
|
+
# How long a leaving worker waits for the parent wakes it started
|
|
34
|
+
# (#await_wakes): a wake only spawns a worker, well under a second.
|
|
35
|
+
WAKE_JOIN_SECONDS = 5
|
|
36
|
+
|
|
37
|
+
# The wake threads #ring started in this process, until #await_wakes.
|
|
38
|
+
@wakes = []
|
|
39
|
+
@wakes_mutex = Mutex.new
|
|
32
40
|
|
|
33
41
|
module_function
|
|
34
42
|
|
|
@@ -70,15 +78,29 @@ module Samagotchi
|
|
|
70
78
|
def wake_parent(parent_id, state_dir:, wake:)
|
|
71
79
|
return if SessionManager.session_owner(parent_id, state_dir: state_dir)
|
|
72
80
|
|
|
73
|
-
if wake
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
|
|
80
|
-
end
|
|
81
|
+
return wake.call(parent_id) if wake
|
|
82
|
+
|
|
83
|
+
thread = Thread.new do
|
|
84
|
+
SessionManager.wake_for_report(parent_id, state_dir: state_dir)
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
|
|
81
87
|
end
|
|
88
|
+
@wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
|
|
89
|
+
thread
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Waits up to +timeout+ for the parent wakes this process started: a
|
|
93
|
+
# worker's teardown, since its process exits right after (a crash's
|
|
94
|
+
# ring comes just before it) and an exit kills a wake midway, before
|
|
95
|
+
# it spawned the parent's worker.
|
|
96
|
+
# @return [Integer] the wakes still running after it
|
|
97
|
+
def await_wakes(timeout = WAKE_JOIN_SECONDS)
|
|
98
|
+
threads = @wakes_mutex.synchronize { @wakes.tap { @wakes = [] } }
|
|
99
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
100
|
+
threads.each { |thread| thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
|
|
101
|
+
left = threads.count(&:alive?)
|
|
102
|
+
Log.warn(:worker, "delegate_wakes_left", count: left) if left.positive?
|
|
103
|
+
left
|
|
82
104
|
end
|
|
83
105
|
end
|
|
84
106
|
|
|
@@ -93,7 +115,6 @@ module Samagotchi
|
|
|
93
115
|
# failed: the next turn reads the same rings again.
|
|
94
116
|
class ChildReports
|
|
95
117
|
SOURCE = Steer::DELEGATE_REPORT
|
|
96
|
-
CLIENT_PREFIX = Steer::CHILD_CLIENT_PREFIX
|
|
97
118
|
|
|
98
119
|
# A report for the model: the text delegate_result would return
|
|
99
120
|
# (DelegateWait.finish, its report variant), the cursor it moves the child to,
|
|
@@ -101,7 +122,7 @@ module Samagotchi
|
|
|
101
122
|
# itself, with delegate_result, leaves it alone).
|
|
102
123
|
Report = Data.define(:child_id, :text, :before, :after, :rings) do
|
|
103
124
|
def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
|
|
104
|
-
def origin = { client_id: "#{
|
|
125
|
+
def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
|
|
105
126
|
end
|
|
106
127
|
|
|
107
128
|
def initialize(session_id:, state_dir:)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "archive_store"
|
|
6
|
+
require_relative "memory_paths"
|
|
7
|
+
require_relative "reply_wait"
|
|
8
|
+
require_relative "tools/delegate"
|
|
9
|
+
require_relative "tools/delegate_cursor"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
13
|
+
# requires KernelLoop and so the tools (a require cycle otherwise).
|
|
14
|
+
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
15
|
+
|
|
16
|
+
# A parent's children as `/children` and the glance views show them: one
|
|
17
|
+
# Child per child session, read from the files layer 1 already writes
|
|
18
|
+
# (the session JSON, its output/ replies, the parent's delegates.json).
|
|
19
|
+
# Nothing here runs git or a subprocess.
|
|
20
|
+
module ChildrenStatus
|
|
21
|
+
# One child of a session.
|
|
22
|
+
# @!attribute state [String] waiting, running, failed, stopped, done or
|
|
23
|
+
# idle (STATES, in the order they are decided)
|
|
24
|
+
# @!attribute waiting [String, nil] what it waits for: question,
|
|
25
|
+
# approval, hook or continue
|
|
26
|
+
# @!attribute delegate [Boolean] a `delegate` child; false for a fork
|
|
27
|
+
# @!attribute branch [String, nil] the branch checked out in its folder
|
|
28
|
+
# (a short sha when detached); nil outside git
|
|
29
|
+
# @!attribute last_reply [String, nil] the first line of its newest reply
|
|
30
|
+
# @!attribute reported [Boolean] the parent was already given that reply
|
|
31
|
+
# (a delegate report or a wait); false when there is none
|
|
32
|
+
Child = Data.define(:id, :short_id, :title, :state, :waiting, :live, :delegate, :cwd, :branch,
|
|
33
|
+
:last_reply, :last_reply_at, :reported, :updated_at, :archived)
|
|
34
|
+
|
|
35
|
+
# The glance views' numbers: children running, waiting for an answer,
|
|
36
|
+
# and with a reply the parent wasn't given yet.
|
|
37
|
+
Counts = Data.define(:running, :waiting, :unreported) do
|
|
38
|
+
def self.none = new(running: 0, waiting: 0, unreported: 0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
STATES = %w[waiting running failed stopped done idle].freeze
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Every child of +parent_id+, newest first: delegates and forks (a fork
|
|
46
|
+
# is delegate: false), archived ones only with +include_archived+. Scans
|
|
47
|
+
# every session (SessionManager.children_of): for on-demand use.
|
|
48
|
+
# @return [Array<Child>]
|
|
49
|
+
def of(parent_id, state_dir:, include_archived: false)
|
|
50
|
+
cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
|
|
51
|
+
SessionManager.children_of(parent_id, state_dir: state_dir).filter_map do |row|
|
|
52
|
+
next if row[:archived] && !include_archived
|
|
53
|
+
|
|
54
|
+
session = load(row[:id], state_dir: state_dir)
|
|
55
|
+
session && child(session, row, cursors, state_dir: state_dir)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The cheap path for the glance views: only the children in the
|
|
60
|
+
# parent's delegates.json (one Session.load and an owner check each, no
|
|
61
|
+
# scan). Its keys can also name a fork the model passed to delegate
|
|
62
|
+
# session: or a deleted child: only live delegates of this parent count,
|
|
63
|
+
# archived ones don't.
|
|
64
|
+
# @return [Counts]
|
|
65
|
+
def counts(parent_id, state_dir:)
|
|
66
|
+
cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
|
|
67
|
+
children = cursors.keys.filter_map do |id|
|
|
68
|
+
session = load(id, state_dir: state_dir)
|
|
69
|
+
next unless session&.delegate? && session.parent_id == parent_id
|
|
70
|
+
next if ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
|
|
71
|
+
|
|
72
|
+
child(session, summary_of(session, state_dir: state_dir), cursors, state_dir: state_dir)
|
|
73
|
+
end
|
|
74
|
+
Counts.new(running: children.count { |c| c.state == "running" },
|
|
75
|
+
waiting: children.count { |c| c.state == "waiting" },
|
|
76
|
+
unreported: children.count { |c| c.last_reply && !c.reported })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param row [Hash] a children_of row, or summary_of's keys
|
|
80
|
+
# @param cursors [Hash] the parent's delegates.json
|
|
81
|
+
def child(session, row, cursors, state_dir:)
|
|
82
|
+
reply = ReplyWait.newest_reply(session.id, state_dir: state_dir)
|
|
83
|
+
cursor = Tools::DelegateCursors.get_from(cursors, session.id)
|
|
84
|
+
Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
|
|
85
|
+
state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
|
|
86
|
+
delegate: session.delegate?, cwd: session.working_directory, branch: branch(session.working_directory),
|
|
87
|
+
last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
|
|
88
|
+
last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
|
|
89
|
+
updated_at: session.updated_at, archived: !!row[:archived])
|
|
90
|
+
end
|
|
91
|
+
private_class_method :child
|
|
92
|
+
|
|
93
|
+
def state_of(session, row, reply)
|
|
94
|
+
return "waiting" if row[:waiting]
|
|
95
|
+
return "running" if Tools::Delegate.running?(row)
|
|
96
|
+
return "stopped" if session.status == Session::STATUS_STOPPED
|
|
97
|
+
|
|
98
|
+
outcome = session.last_turn.is_a?(Hash) ? session.last_turn["outcome"] : nil
|
|
99
|
+
return "failed" if session.status == Session::STATUS_ERROR || outcome == "failed"
|
|
100
|
+
return "done" if outcome == "completed" && reply
|
|
101
|
+
|
|
102
|
+
"idle"
|
|
103
|
+
end
|
|
104
|
+
private_class_method :state_of
|
|
105
|
+
|
|
106
|
+
# The keys of a children_of row that state_of and child read, for a
|
|
107
|
+
# session counts loaded itself.
|
|
108
|
+
def summary_of(session, state_dir:)
|
|
109
|
+
live = SessionManager.worker_live?(session.id, state_dir: state_dir)
|
|
110
|
+
{ live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
|
|
111
|
+
updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
|
|
112
|
+
preview: session.last_prompt.to_s }
|
|
113
|
+
end
|
|
114
|
+
private_class_method :summary_of
|
|
115
|
+
|
|
116
|
+
# The branch from the folder's own HEAD file (a linked worktree's is
|
|
117
|
+
# under .git/worktrees/<name>), read without git.
|
|
118
|
+
# @return [String, nil]
|
|
119
|
+
def branch(cwd)
|
|
120
|
+
dir = MemoryPaths.git_dir(cwd.to_s)
|
|
121
|
+
head = dir && File.read(File.join(dir, "HEAD"), 512).strip
|
|
122
|
+
return nil if head.nil? || head.empty?
|
|
123
|
+
|
|
124
|
+
head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
private_class_method :branch
|
|
129
|
+
|
|
130
|
+
def first_line(id, file, state_dir:)
|
|
131
|
+
text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
|
|
132
|
+
text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
private_class_method :first_line
|
|
137
|
+
|
|
138
|
+
# Reply files are named by the local time they were written
|
|
139
|
+
# (SessionInbox.write_output, %Y%m%d%H%M%S then nanoseconds).
|
|
140
|
+
# @return [Time, nil] nil for a name that isn't one
|
|
141
|
+
def reply_time(file)
|
|
142
|
+
Time.strptime(File.basename(file, ".txt")[0, 14], "%Y%m%d%H%M%S")
|
|
143
|
+
rescue ArgumentError, TypeError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
private_class_method :reply_time
|
|
147
|
+
|
|
148
|
+
def load(id, state_dir:)
|
|
149
|
+
return nil unless Session.exist?(id, state_dir: state_dir)
|
|
150
|
+
|
|
151
|
+
Session.load(id, state_dir: state_dir)
|
|
152
|
+
rescue ArgumentError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
private_class_method :load
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Who sent a turn, a line or an answer: its client_id, as a turn's origin,
|
|
5
|
+
# an input file and the events carry it. The one table of the ids chi
|
|
6
|
+
# makes, and of which of them are a human's input (#human?). No requires,
|
|
7
|
+
# so any file can use it.
|
|
8
|
+
module ClientId
|
|
9
|
+
# A web tab (web:<random>, per tab); web:restart, the page's Restart.
|
|
10
|
+
WEB_PREFIX = "web:"
|
|
11
|
+
WEB_RESTART = "web:restart"
|
|
12
|
+
# An attached chi TUI: tui:<pid>.
|
|
13
|
+
TUI_PREFIX = "tui:"
|
|
14
|
+
# chi send's turns and commands.
|
|
15
|
+
CLI_SEND = "cli:send"
|
|
16
|
+
# chi answer's answers (a parent agent's, Guardrails::ParentApprovals).
|
|
17
|
+
CLI_ANSWER = "cli:answer"
|
|
18
|
+
# chi restart.
|
|
19
|
+
CLI_RESTART = "cli:restart"
|
|
20
|
+
# A delegating parent's task or follow-up: delegate:<parent8>.
|
|
21
|
+
DELEGATE_PREFIX = "delegate:"
|
|
22
|
+
# A delegate child's report (ChildReports): child:<child8>.
|
|
23
|
+
CHILD_PREFIX = "child:"
|
|
24
|
+
# A turn chi ran because an attached context source changed:
|
|
25
|
+
# context:<name> (Worker's context wake).
|
|
26
|
+
CONTEXT_PREFIX = "context:"
|
|
27
|
+
# chi's own turns; system:reminder, the turn queued when reminders are due.
|
|
28
|
+
SYSTEM_PREFIX = "system:"
|
|
29
|
+
REMINDER = "system:reminder"
|
|
30
|
+
# A plugin's message (Plugin::Sessions#send).
|
|
31
|
+
PLUGIN = "plugin"
|
|
32
|
+
# A relayed answer (RelayVerifier): relay:<parent8>.
|
|
33
|
+
RELAY_PREFIX = "relay:"
|
|
34
|
+
|
|
35
|
+
# Input a human typed: a web tab, a chi TUI, chi send, and nil (a
|
|
36
|
+
# worker's initial prompt; a web client may send none). An allowlist:
|
|
37
|
+
# delegates, plugins, reminders, context changes and any id chi doesn't
|
|
38
|
+
# know are not.
|
|
39
|
+
HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
40
|
+
HUMAN_IDS = [CLI_SEND].freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Whether +client_id+ (a turn's or a line's origin) is a human's input.
|
|
45
|
+
def human?(client_id)
|
|
46
|
+
return true if client_id.nil?
|
|
47
|
+
|
|
48
|
+
id = client_id.to_s
|
|
49
|
+
HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -118,7 +118,8 @@ module Samagotchi
|
|
|
118
118
|
if sub.seen != snapshot.revision
|
|
119
119
|
args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
|
|
120
120
|
found = delivery(name, text_note_id(name, snapshot), self.class.updated_text(**args), now, seen(sub, snapshot))
|
|
121
|
-
|
|
121
|
+
# Any revision since the one the session last saw may have asked.
|
|
122
|
+
return found unless snapshot.wake_serial.to_i > sub.seen_serial
|
|
122
123
|
|
|
123
124
|
return found.with(wake_note: found.note.merge(text: self.class.updated_text(**args, wake: true)))
|
|
124
125
|
end
|