samagotchi 0.33.0 → 0.35.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 +91 -1
- data/README.md +6 -1
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +5 -0
- data/docs/guardrails.md +35 -3
- data/docs/hooks.md +25 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +78 -3
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +15 -9
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- 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/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- 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 +8 -2
- data/lib/samagotchi/children_status.rb +143 -0
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +45 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/git_head.rb +24 -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/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +14 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/plugin/sessions.rb +45 -1
- data/lib/samagotchi/process_group.rb +2 -5
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +21 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +103 -14
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +173 -309
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +24 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.35.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:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
|
|
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
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
- `[CONTEXT NOTE from …]` messages are context notes (`chi note`, or another session's
|
|
20
20
|
`send_note`): background, not requests. `list_sessions` + `send_note` tell another session
|
|
21
21
|
something without starting a turn there; see `docs/sessions.md` "Context notes".
|
|
22
|
+
- `[CONTEXT NOTE from broadcast, …]` is my user's `chi broadcast`: one note shared with every
|
|
23
|
+
session it may concern (a shared ticket, PR or link, or all of them). If it affects my current
|
|
24
|
+
work I say so briefly in my next answer, else I ignore it; I don't act on it unless asked. Only
|
|
25
|
+
my user runs `chi broadcast`; see `docs/broadcast.md`.
|
|
22
26
|
- `[CONTEXT NOTE from context <name>, …]` notes are attached context (`chi context`): an external
|
|
23
27
|
source chi keeps fresh (a PR, a thread, a script's output) and notes when it changes. Read it
|
|
24
28
|
with `context_read` (no name: the list) when my user's request is about it; its text is
|
|
@@ -30,8 +34,10 @@
|
|
|
30
34
|
`chi send --new --wait -m TEXT` starts a session the user can watch in the web and prints its
|
|
31
35
|
answer (exit 3: it waits for the user's answer); see "Starting a session".
|
|
32
36
|
- `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
|
|
37
|
+
reply (`wait: false` is the usual way: I end my turn or keep talking with my user, and chi
|
|
38
|
+
brings the reply to me by itself as a delegate report when the child ends its turn, starting a
|
|
39
|
+
turn for it if I'm idle; `delegate_result` only when this turn can't go on without the reply;
|
|
40
|
+
`session:` sends a follow-up to a child). A child is a
|
|
35
41
|
normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
|
|
36
42
|
with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
|
|
37
43
|
approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "archive_store"
|
|
6
|
+
require_relative "git_head"
|
|
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: GitHead.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
|
+
def first_line(id, file, state_dir:)
|
|
117
|
+
text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
|
|
118
|
+
text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
|
|
119
|
+
rescue SystemCallError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
private_class_method :first_line
|
|
123
|
+
|
|
124
|
+
# Reply files are named by the local time they were written
|
|
125
|
+
# (SessionInbox.write_output, %Y%m%d%H%M%S then nanoseconds).
|
|
126
|
+
# @return [Time, nil] nil for a name that isn't one
|
|
127
|
+
def reply_time(file)
|
|
128
|
+
Time.strptime(File.basename(file, ".txt")[0, 14], "%Y%m%d%H%M%S")
|
|
129
|
+
rescue ArgumentError, TypeError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
private_class_method :reply_time
|
|
133
|
+
|
|
134
|
+
def load(id, state_dir:)
|
|
135
|
+
return nil unless Session.exist?(id, state_dir: state_dir)
|
|
136
|
+
|
|
137
|
+
Session.load(id, state_dir: state_dir)
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
private_class_method :load
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -84,6 +84,12 @@ module Samagotchi
|
|
|
84
84
|
# "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
|
|
85
85
|
Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli]),
|
|
86
86
|
|
|
87
|
+
# chi broadcast (Broadcast::Recipients, Broadcast::Tags): a session that ended no turn in this many hours (and
|
|
88
|
+
# no worker or REPL runs) gets no broadcast; a ticket id in the note and in a session's branch or prompts
|
|
89
|
+
# delivers it there without triage.
|
|
90
|
+
Entry.new(key: "broadcast.active_hours", yaml_path: %w[broadcast active_hours], type: :float, default: 8.0, expose: %i[env config]),
|
|
91
|
+
Entry.new(key: "broadcast.ticket_pattern", yaml_path: %w[broadcast ticket_pattern], type: :string, default: '\b[A-Z][A-Z0-9]+-\d+\b', expose: %i[env config]),
|
|
92
|
+
|
|
87
93
|
Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
|
|
88
94
|
Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
|
|
89
95
|
Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli]),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "turn_note"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# What a turn does after a generation was cut (stop_generation: a
|
|
7
|
+
# plugin's cut, or Engine#cut_for_steer's), shared by the native loop
|
|
8
|
+
# (KernelLoop) and the chat loop (ChatLoop::Run); each maps the outcome to
|
|
9
|
+
# its own ending.
|
|
10
|
+
#
|
|
11
|
+
# A generation a plugin cut is an empty answer made early. Queued input
|
|
12
|
+
# (a user's line, a plugin's steer) goes in first, with or without a
|
|
13
|
+
# retry left, and spends no attempt: the model answers it. A steer's cut
|
|
14
|
+
# with nothing queued (its message was taken at a boundary already) is
|
|
15
|
+
# asked again as is. Else it is asked again with its own nudge while the
|
|
16
|
+
# retry budget lasts, else the turn ends as cancelled (hook), with
|
|
17
|
+
# nothing salvaged and without the spent nudge. A Stop that came right
|
|
18
|
+
# after the cut is a plain cancel.
|
|
19
|
+
module CutPolicy
|
|
20
|
+
# :stopped (a Stop after the cut), :again (the turn goes on) or :hook
|
|
21
|
+
# (the turn ends as cancelled by the cut's bundle).
|
|
22
|
+
Outcome = Data.define(:kind) do
|
|
23
|
+
def stopped? = kind == :stopped
|
|
24
|
+
def again? = kind == :again
|
|
25
|
+
def hook? = kind == :hook
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
STOPPED = Outcome.new(kind: :stopped)
|
|
29
|
+
AGAIN = Outcome.new(kind: :again)
|
|
30
|
+
HOOK = Outcome.new(kind: :hook)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# +cut+: the cut's detail (the stop_generation payload). +emit+ takes an
|
|
35
|
+
# event Hash; +inject+ puts queued input in (true when there was any).
|
|
36
|
+
# +finish_reason+ and +thinking_chars+: the cut generation's, for the
|
|
37
|
+
# retry's row.
|
|
38
|
+
# On :hook the turn controller is cancelled (:hook, +cut+) already.
|
|
39
|
+
def decide(cut:, cancel_controller:, empty_retry:, conversation:, iteration:, emit:, inject:, finish_reason:,
|
|
40
|
+
thinking_chars:)
|
|
41
|
+
return STOPPED if cancel_controller.cancelled?
|
|
42
|
+
|
|
43
|
+
# The row under the cut step, above the message it was cut for.
|
|
44
|
+
emit.call({ type: :steer_cut, iteration: iteration, source: cut[:source].to_s }) if cut[:steer]
|
|
45
|
+
return AGAIN if inject.call || cut[:steer]
|
|
46
|
+
|
|
47
|
+
if empty_retry.left?
|
|
48
|
+
empty_retry.nudge!(conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
|
|
49
|
+
emit: emit, iteration: iteration, finish_reason: finish_reason,
|
|
50
|
+
thinking_chars: thinking_chars, stopped_by: cut[:by])
|
|
51
|
+
return AGAIN
|
|
52
|
+
end
|
|
53
|
+
empty_retry.drop_nudge!(conversation)
|
|
54
|
+
cancel_controller.cancel!(:hook, cut)
|
|
55
|
+
HOOK
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -49,8 +49,8 @@ require_relative "tools/memory"
|
|
|
49
49
|
require_relative "muted_memories"
|
|
50
50
|
require_relative "used_memories"
|
|
51
51
|
require_relative "turn_state"
|
|
52
|
-
require_relative "
|
|
53
|
-
require_relative "
|
|
52
|
+
require_relative "steer_cut"
|
|
53
|
+
require_relative "engine_peers"
|
|
54
54
|
require_relative "bundle_needs"
|
|
55
55
|
require_relative "model_overlay"
|
|
56
56
|
require_relative "served_model"
|
|
@@ -109,10 +109,8 @@ module Samagotchi
|
|
|
109
109
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
110
110
|
# may steer or ask whether a turn runs while it loads.
|
|
111
111
|
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
112
|
-
#
|
|
113
|
-
@
|
|
114
|
-
# A message that may cut it but came too early: it cuts later.
|
|
115
|
-
@waiting_steer = WaitingSteer.new
|
|
112
|
+
# A message for the running turn cutting its generation.
|
|
113
|
+
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller })
|
|
116
114
|
@scratch = scratch
|
|
117
115
|
@no_interrupt = no_interrupt
|
|
118
116
|
@chat_backend = nil
|
|
@@ -205,7 +203,7 @@ module Samagotchi
|
|
|
205
203
|
emit: ->(sink, event) { emit_event(sink, event) },
|
|
206
204
|
show_card: ->(**card) { show_card(**card) },
|
|
207
205
|
tools: -> { @tools },
|
|
208
|
-
notify: ->(text, level, source) { hook_notify(text, level, source) },
|
|
206
|
+
notify: ->(text, level, source, fallback_for: nil) { hook_notify(text, level, source, fallback_for: fallback_for) },
|
|
209
207
|
tools_changed: -> { tools_changed! }
|
|
210
208
|
)
|
|
211
209
|
@lifecycle_mutex = Mutex.new
|
|
@@ -343,69 +341,15 @@ module Samagotchi
|
|
|
343
341
|
@turn_state.steer(text, source: source)
|
|
344
342
|
end
|
|
345
343
|
|
|
346
|
-
# A message for the running turn from +source+
|
|
347
|
-
#
|
|
348
|
-
#
|
|
349
|
-
# seconds (GenerationPhase): the loops' cut path then starts the step
|
|
350
|
-
# again with the message, no nudge, no retry spent. Too early, the
|
|
351
|
-
# message waits (WaitingSteer) and cuts once the thinking passes it
|
|
352
|
-
# (#recheck_waiting_steer, on each thinking chunk), unless a boundary
|
|
353
|
-
# hands it to the model first. A plugin's message never cuts, nor an
|
|
354
|
-
# unknown client's (automatic:<id>).
|
|
355
|
-
# Call it after the message is queued, with the #input_epoch read
|
|
356
|
-
# before queueing it. Outside any lock; a cut that lands just after the
|
|
357
|
-
# generation ended is harmless (the loop re-asks).
|
|
344
|
+
# A message for the running turn from +source+ cuts the generation
|
|
345
|
+
# streaming only thinking (SteerCut#cut_for_steer). Call it after the
|
|
346
|
+
# message is queued, with the #input_epoch read before queueing it.
|
|
358
347
|
# @return [Boolean] whether a generation was cut now
|
|
359
|
-
def cut_for_steer(source, epoch: nil)
|
|
360
|
-
return false unless Steer.cuts?(source)
|
|
348
|
+
def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
|
|
361
349
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
ctrl = active_cancel_controller
|
|
366
|
-
return false unless ctrl
|
|
367
|
-
return steer_cut!(ctrl, source.to_s) if @generation_phase.cuttable?(after)
|
|
368
|
-
|
|
369
|
-
Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting_steer.wait!(source, epoch: epoch)
|
|
370
|
-
false
|
|
371
|
-
rescue StandardError
|
|
372
|
-
false
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# The drains that took input so far (WaitingSteer#epoch): read it before
|
|
376
|
-
# queueing a message for #cut_for_steer.
|
|
377
|
-
def input_epoch
|
|
378
|
-
@waiting_steer.epoch
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# A thinking chunk streamed: a waiting message cuts once the thinking
|
|
382
|
-
# passes steer.cut_after.
|
|
383
|
-
def recheck_waiting_steer
|
|
384
|
-
return unless @waiting_steer.waiting?
|
|
385
|
-
|
|
386
|
-
after = Config.get("steer.cut_after").to_i
|
|
387
|
-
return unless after.positive? && @generation_phase.cuttable?(after)
|
|
388
|
-
|
|
389
|
-
source = @waiting_steer.take
|
|
390
|
-
ctrl = active_cancel_controller
|
|
391
|
-
steer_cut!(ctrl, source, waited: true) if source && ctrl
|
|
392
|
-
rescue StandardError
|
|
393
|
-
nil
|
|
394
|
-
end
|
|
395
|
-
private :recheck_waiting_steer
|
|
396
|
-
|
|
397
|
-
def steer_cut!(ctrl, source, waited: false)
|
|
398
|
-
age = @generation_phase.age
|
|
399
|
-
cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
|
|
400
|
-
if cut
|
|
401
|
-
@waiting_steer.clear!
|
|
402
|
-
fields = { source: source, age: age&.round(1) }
|
|
403
|
-
fields[:waited] = true if waited
|
|
404
|
-
Log.info(:turn, "steer_cut", **fields)
|
|
405
|
-
end
|
|
406
|
-
cut
|
|
407
|
-
end
|
|
408
|
-
private :steer_cut!
|
|
350
|
+
# The drains that took input so far: read it before queueing a message
|
|
351
|
+
# for #cut_for_steer.
|
|
352
|
+
def input_epoch = @steer_cut.input_epoch
|
|
409
353
|
|
|
410
354
|
# Like #steer, for the turn that begins next (a continue turn answered
|
|
411
355
|
# with a text): it joins that turn at its first boundary. Callable from
|
|
@@ -432,7 +376,7 @@ module Samagotchi
|
|
|
432
376
|
lambda do |at_answer: false|
|
|
433
377
|
lines = pending_input ? Array(pending_input.call) : []
|
|
434
378
|
# The input went to the model: a message that waited to cut is in.
|
|
435
|
-
@
|
|
379
|
+
@steer_cut.delivered! unless lines.empty?
|
|
436
380
|
lines + take_steers(at_answer)
|
|
437
381
|
end
|
|
438
382
|
end
|
|
@@ -1279,7 +1223,7 @@ module Samagotchi
|
|
|
1279
1223
|
# hook's label (event[:hook]) from the registry.
|
|
1280
1224
|
def hook_runtime
|
|
1281
1225
|
Hooks::Runtime.new(
|
|
1282
|
-
notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
|
|
1226
|
+
notify: ->(text:, level:, hook:, fallback_for: nil) { hook_notify(text, level, hook, fallback_for: fallback_for) },
|
|
1283
1227
|
ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
|
|
1284
1228
|
stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
|
|
1285
1229
|
steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
|
|
@@ -1295,14 +1239,24 @@ module Samagotchi
|
|
|
1295
1239
|
end
|
|
1296
1240
|
private :hook_source
|
|
1297
1241
|
|
|
1242
|
+
# What a notice may stand in for (hook_notify's fallback_for:).
|
|
1243
|
+
NOTICE_FALLBACKS = %i[display].freeze
|
|
1244
|
+
|
|
1298
1245
|
# One line to the user (:hook_notice). During a turn it is a turn
|
|
1299
1246
|
# event: the turn's sink (the REPL) and the observers (bridge, log).
|
|
1300
1247
|
# Outside one (a plugin's command at the prompt) it is announced with
|
|
1301
1248
|
# between_turns: true, which every UI shows as cards are shown.
|
|
1302
|
-
|
|
1249
|
+
#
|
|
1250
|
+
# +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
|
|
1251
|
+
# :display, the answer's display (AnswerDisplay), for a UI that doesn't
|
|
1252
|
+
# render its links. A UI that does leaves the line out; the others, the
|
|
1253
|
+
# log among them, show it. An unknown value is dropped, so the line
|
|
1254
|
+
# shows everywhere.
|
|
1255
|
+
def hook_notify(text, level, hook, fallback_for: nil)
|
|
1303
1256
|
level = (level || :info).to_sym
|
|
1304
1257
|
level = :info unless %i[info warn].include?(level)
|
|
1305
|
-
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level
|
|
1258
|
+
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
|
|
1259
|
+
fallback_for: notice_fallback(fallback_for, hook) }.compact
|
|
1306
1260
|
return hold_load_event(notice) && nil if @loading_plugins
|
|
1307
1261
|
|
|
1308
1262
|
in_turn, sink = @turn_state.in_turn_sink
|
|
@@ -1319,6 +1273,20 @@ module Samagotchi
|
|
|
1319
1273
|
end
|
|
1320
1274
|
private :hook_notify
|
|
1321
1275
|
|
|
1276
|
+
# A known fallback_for (a String is symbolized), else nil; an unknown
|
|
1277
|
+
# one is logged once at debug.
|
|
1278
|
+
def notice_fallback(value, hook)
|
|
1279
|
+
return if value.nil?
|
|
1280
|
+
|
|
1281
|
+
fallback = value.to_s.to_sym
|
|
1282
|
+
return fallback if NOTICE_FALLBACKS.include?(fallback)
|
|
1283
|
+
|
|
1284
|
+
@unknown_fallbacks ||= Set.new
|
|
1285
|
+
Log.debug(:hooks, "notice_unknown_fallback", hook: hook.to_s, value: value.to_s) if @unknown_fallbacks.add?(fallback)
|
|
1286
|
+
nil
|
|
1287
|
+
end
|
|
1288
|
+
private :notice_fallback
|
|
1289
|
+
|
|
1322
1290
|
# A guardrails line to the user after load (:guardrail_warning, as the
|
|
1323
1291
|
# load failures show): a running turn's event, else announced.
|
|
1324
1292
|
def guardrail_notify(message)
|
|
@@ -1459,28 +1427,6 @@ module Samagotchi
|
|
|
1459
1427
|
# @return [Session] current session (Engine owns create/resume)
|
|
1460
1428
|
attr_reader :session
|
|
1461
1429
|
|
|
1462
|
-
# The kernel's Tools::Peers, following the current session. cancelled?
|
|
1463
|
-
# is the running turn's cancel, for a tool that waits (delegate_result):
|
|
1464
|
-
# the controller is set from another thread and a tool gets no other
|
|
1465
|
-
# way to see it.
|
|
1466
|
-
PeerView = Struct.new(:engine) do
|
|
1467
|
-
def session_id = engine.session&.id
|
|
1468
|
-
def cwd = engine.session&.working_directory
|
|
1469
|
-
def project_root = engine.session&.project_root
|
|
1470
|
-
def state_dir = engine.peer_state_dir
|
|
1471
|
-
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
1472
|
-
def relay = engine.relay_peer
|
|
1473
|
-
def model_ref = engine.effective_model_ref
|
|
1474
|
-
end
|
|
1475
|
-
|
|
1476
|
-
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
1477
|
-
# its own question flow, which every UI attached to it answers.
|
|
1478
|
-
RelayPeer = Struct.new(:engine) do
|
|
1479
|
-
def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
|
|
1480
|
-
def interface = engine.interface
|
|
1481
|
-
def relay_desk = engine.relay_desk
|
|
1482
|
-
end
|
|
1483
|
-
|
|
1484
1430
|
# This worker's relays of its delegates' approvals (memory only).
|
|
1485
1431
|
# @return [RelayDesk]
|
|
1486
1432
|
attr_reader :relay_desk
|
|
@@ -2015,8 +1961,7 @@ module Samagotchi
|
|
|
2015
1961
|
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
2016
1962
|
# treats the just-finished turn as activity and re-arms its window.
|
|
2017
1963
|
left = @turn_state.finish!
|
|
2018
|
-
@
|
|
2019
|
-
@waiting_steer.clear!
|
|
1964
|
+
@steer_cut.finished!
|
|
2020
1965
|
Client.swap_probe_cancel(probe_cancel_before)
|
|
2021
1966
|
# The next turn's window and served model are asked again: the cache
|
|
2022
1967
|
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
@@ -2364,7 +2309,7 @@ module Samagotchi
|
|
|
2364
2309
|
cwd: -> { @session&.working_directory },
|
|
2365
2310
|
messages: -> { plugin_messages },
|
|
2366
2311
|
messages_partial: -> { turn_running? && @running_turn_messages.nil? },
|
|
2367
|
-
notify: ->(text, level, label) { hook_notify(text, level, label) },
|
|
2312
|
+
notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
|
|
2368
2313
|
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
|
|
2369
2314
|
hook_ask_user(question, options, header, allow_freeform, hook)
|
|
2370
2315
|
},
|
|
@@ -2473,21 +2418,13 @@ module Samagotchi
|
|
|
2473
2418
|
def build_stream_event_handler(on_event, cancel_controller: nil)
|
|
2474
2419
|
watch = stream_watch(cancel_controller)
|
|
2475
2420
|
proc do |event|
|
|
2476
|
-
progress =
|
|
2421
|
+
progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
|
|
2422
|
+
@steer_cut.observe(event, progress)
|
|
2477
2423
|
case event[:type]
|
|
2478
2424
|
when :generation_started
|
|
2479
|
-
@generation_phase.started!
|
|
2480
2425
|
@generation_thought = nil
|
|
2481
2426
|
watch&.started(event[:iteration])
|
|
2482
|
-
when :generation_chunk
|
|
2483
|
-
# A chunk without the lanes (no loop of ours sends one) counts as text.
|
|
2484
|
-
text = event.key?(:text) ? event[:text] : event[:content]
|
|
2485
|
-
progress = { thinking: event[:thinking].to_s, text: text.to_s }
|
|
2486
|
-
@generation_phase.chunk!(**progress, tool_call: event[:tool_call])
|
|
2487
|
-
recheck_waiting_steer unless progress[:thinking].empty?
|
|
2488
|
-
note_generation_thought(progress[:thinking])
|
|
2489
|
-
when :generation_retrying then @generation_phase.retrying!
|
|
2490
|
-
when :generation_completed, :generation_cancelled then @generation_phase.finished!
|
|
2427
|
+
when :generation_chunk then note_generation_thought(progress[:thinking])
|
|
2491
2428
|
end
|
|
2492
2429
|
# The chat loop asked again without the thinking fields: a notice,
|
|
2493
2430
|
# not an event of its own.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The views of an Engine its peers get: the kernel's tools
|
|
5
|
+
# (Engine#initialize sets @kernel.peers) and a delegate's approval relay
|
|
6
|
+
# (Engine#relay_peer).
|
|
7
|
+
class Engine
|
|
8
|
+
# The kernel's Tools::Peers, following the current session. cancelled?
|
|
9
|
+
# is the running turn's cancel, for a tool that waits (delegate_result):
|
|
10
|
+
# the controller is set from another thread and a tool gets no other
|
|
11
|
+
# way to see it.
|
|
12
|
+
PeerView = Struct.new(:engine) do
|
|
13
|
+
def session_id = engine.session&.id
|
|
14
|
+
def cwd = engine.session&.working_directory
|
|
15
|
+
def project_root = engine.session&.project_root
|
|
16
|
+
def state_dir = engine.peer_state_dir
|
|
17
|
+
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
18
|
+
def relay = engine.relay_peer
|
|
19
|
+
def model_ref = engine.effective_model_ref
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
23
|
+
# its own question flow, which every UI attached to it answers.
|
|
24
|
+
RelayPeer = Struct.new(:engine) do
|
|
25
|
+
def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
|
|
26
|
+
def interface = engine.interface
|
|
27
|
+
def relay_desk = engine.relay_desk
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "memory_paths"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# What a folder has checked out, read from its HEAD file without running
|
|
7
|
+
# git (cheap enough for a list of sessions).
|
|
8
|
+
module GitHead
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# The branch checked out in +dir+'s repository (a linked worktree's own
|
|
12
|
+
# HEAD is under .git/worktrees/<name>), a short sha when detached.
|
|
13
|
+
# @return [String, nil] nil outside git or for an unreadable HEAD
|
|
14
|
+
def branch(dir)
|
|
15
|
+
git_dir = MemoryPaths.git_dir(dir.to_s)
|
|
16
|
+
head = git_dir && File.read(File.join(git_dir, "HEAD"), 512).to_s.strip
|
|
17
|
+
return nil if head.nil? || head.empty?
|
|
18
|
+
|
|
19
|
+
head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
|
|
20
|
+
rescue SystemCallError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -104,11 +104,26 @@ module Samagotchi
|
|
|
104
104
|
)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# The gate's core checks, in order.
|
|
107
|
+
# The gate's core checks, in order. A delegate child's boundary comes
|
|
108
|
+
# before the rules, so its ask keeps the child-boundary rule id (and
|
|
109
|
+
# its once/session scopes) when a strict rule asks too.
|
|
108
110
|
def checks
|
|
109
111
|
rules = self.rules
|
|
110
112
|
[@failures, rules.hook_asks, protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
|
|
111
|
-
rules].compact
|
|
113
|
+
child_boundary, rules].compact
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The delegate child's boundary (Guardrails::ChildBoundary); nil for
|
|
117
|
+
# any other session.
|
|
118
|
+
def child_boundary
|
|
119
|
+
return nil unless @session_lookup.call&.delegate?
|
|
120
|
+
|
|
121
|
+
@child_boundary ||= Guardrails::ChildBoundary.new(
|
|
122
|
+
root: -> { boundary_root },
|
|
123
|
+
worktrees: -> { (root = boundary_root) ? @git.worktrees(root) : [] },
|
|
124
|
+
common_dir: -> { (root = boundary_root) && @git.common_dir(root) },
|
|
125
|
+
branch: -> { (root = boundary_root) && @git.branch(root) }
|
|
126
|
+
)
|
|
112
127
|
end
|
|
113
128
|
|
|
114
129
|
# The YAML rules: config.yml's `guardrails:` section (rules, disable) and
|
|
@@ -221,6 +236,16 @@ module Samagotchi
|
|
|
221
236
|
|
|
222
237
|
private
|
|
223
238
|
|
|
239
|
+
# The work tree top of the session's working_directory (the folder
|
|
240
|
+
# itself outside a repo, or when it's gone); never Dir.pwd, which is
|
|
241
|
+
# the waker's cwd for a worker whose folder is gone.
|
|
242
|
+
def boundary_root
|
|
243
|
+
dir = @session_lookup.call&.working_directory.to_s
|
|
244
|
+
return nil if dir.empty?
|
|
245
|
+
|
|
246
|
+
@git.root(dir) || File.expand_path(dir)
|
|
247
|
+
end
|
|
248
|
+
|
|
224
249
|
# The rules of a bundle that needs a newer chi: the ones last loaded
|
|
225
250
|
# from it, with one notice per bundle version (restart the session to
|
|
226
251
|
# load the new ones). Never loaded (a chi older than the bundle it
|
|
@@ -26,6 +26,10 @@ module Samagotchi
|
|
|
26
26
|
class Approvals
|
|
27
27
|
FILE = "approvals.json"
|
|
28
28
|
STORED_SCOPES = %w[session repo rule].freeze
|
|
29
|
+
# Rules a stored answer allows only for its session: their asks offer
|
|
30
|
+
# once/session, and a repo entry for the same call (from another
|
|
31
|
+
# rule) would hold in every worktree, for every child.
|
|
32
|
+
SESSION_ONLY_RULES = %w[child-boundary].freeze
|
|
29
33
|
|
|
30
34
|
# @param sessions_dir [String] Session's state dir
|
|
31
35
|
# ($XDG_STATE_HOME/samagotchi/sessions): the store sits beside it,
|
|
@@ -69,7 +73,10 @@ module Samagotchi
|
|
|
69
73
|
repo = verdict.targets&.repo
|
|
70
74
|
here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
|
|
71
75
|
session_id = verdict.context&.session_id
|
|
76
|
+
session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
|
|
72
77
|
list.find do |e|
|
|
78
|
+
next false if session_only && e["scope"] != "session"
|
|
79
|
+
|
|
73
80
|
case e["scope"]
|
|
74
81
|
when "session" then session_id && e["session_id"] == session_id && e["key"] == key
|
|
75
82
|
when "repo" then here.call(e) && e["key"] == key
|