samagotchi 0.31.0 → 0.32.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 +42 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +3 -1
- data/docs/context.md +218 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +1 -0
- data/docs/plugins.md +77 -1
- data/docs/releasing.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +6 -0
- data/lib/samagotchi/child_reports.rb +0 -2
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/context_absorber.rb +179 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +226 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +452 -0
- data/lib/samagotchi/engine.rb +10 -5
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/question_desk.rb +0 -3
- data/lib/samagotchi/session.rb +7 -5
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +12 -3
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -3
- data/lib/samagotchi/terminal_ui.rb +0 -3
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_declarations.rb +21 -0
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +109 -1
- data/lib/samagotchi/web/public/app.js +40 -10
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +21 -0
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +5 -3
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +8 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -1
- data/lib/samagotchi/worker.rb +126 -0
- metadata +14 -1
|
@@ -11,16 +11,22 @@ module Samagotchi
|
|
|
11
11
|
KIND = "note"
|
|
12
12
|
# The message keys a note carries besides role and content; everything
|
|
13
13
|
# that copies messages field by field must keep them.
|
|
14
|
-
|
|
14
|
+
# context_source: the attached context source a note is about
|
|
15
|
+
# (ContextAbsorber), so the UIs can tie it to its chip.
|
|
16
|
+
KEYS = %i[kind note_id source from_session from_cwd context_source].freeze
|
|
15
17
|
|
|
16
18
|
module_function
|
|
17
19
|
|
|
18
|
-
# Takes SessionInbox.read_note's keys.
|
|
20
|
+
# Takes SessionInbox.read_note's keys. +turn_start+ / +turn_id+: the
|
|
21
|
+
# note starts a turn chi runs for it (a context wake), as a wake turn's
|
|
22
|
+
# delegate report does (Steer.turn_prompt?).
|
|
19
23
|
# @return [Hash] the conversation message
|
|
20
|
-
def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil,
|
|
24
|
+
def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, context_source: nil,
|
|
25
|
+
turn_start: nil, turn_id: nil, **)
|
|
21
26
|
{ role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
|
|
22
|
-
from_cwd: from_cwd,
|
|
23
|
-
|
|
27
|
+
from_cwd: from_cwd, context_source: context_source, turn_start: (true if turn_start == true), turn_id: turn_id,
|
|
28
|
+
content: frame(text, source: source, created_at: created_at, from_session: from_session,
|
|
29
|
+
from_cwd: from_cwd) }.compact
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "context_fetch"
|
|
5
|
+
require_relative "context_sources"
|
|
6
|
+
require_relative "log"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# A session worker's thread that keeps its attached context fresh: every
|
|
10
|
+
# TICK_SECONDS it runs each command source (the session's and its
|
|
11
|
+
# project's, not muted here) whose last try is older than its interval
|
|
12
|
+
# (--every, else context.every_seconds), and calls +on_change+ after a
|
|
13
|
+
# new text or a failure so the loop absorbs it at once. The first pass,
|
|
14
|
+
# right after the worker starts, runs every source not tried in the last
|
|
15
|
+
# MIN_EVERY_SECONDS: a woken worker's first absorb says what changed while
|
|
16
|
+
# it was away.
|
|
17
|
+
#
|
|
18
|
+
# Polling isn't activity: it never holds the worker up (WorkerIdleExit),
|
|
19
|
+
# and it stops with it (#stop kills a running command's group). Project
|
|
20
|
+
# sources are shared by the project's workers; ContextFetch's lock and a
|
|
21
|
+
# second freshness check under it make one fetch per interval.
|
|
22
|
+
class ContextPoller
|
|
23
|
+
TICK_SECONDS = 5
|
|
24
|
+
|
|
25
|
+
# @param project_root [String, nil] Session#project_root
|
|
26
|
+
# @param cwd [String] the session's folder (a session source runs there)
|
|
27
|
+
# @param on_change [#call] after a fetch brought a new text or failed
|
|
28
|
+
def initialize(session_id:, state_dir:, project_root:, cwd:, on_change:, tick: TICK_SECONDS)
|
|
29
|
+
@session_id = session_id
|
|
30
|
+
@state_dir = state_dir
|
|
31
|
+
@project_root = project_root
|
|
32
|
+
@cwd = cwd
|
|
33
|
+
@on_change = on_change
|
|
34
|
+
@tick = tick
|
|
35
|
+
@own = ContextSources.session_location(session_id, state_dir: state_dir)
|
|
36
|
+
@queue = Thread::Queue.new
|
|
37
|
+
@stopping = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start
|
|
41
|
+
@thread ||= Thread.new do
|
|
42
|
+
Thread.current.name = "context-poller"
|
|
43
|
+
run
|
|
44
|
+
end
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Stops the loop and kills a command it is running; returns once it has.
|
|
49
|
+
def stop
|
|
50
|
+
@stopping = true
|
|
51
|
+
@queue << :stop
|
|
52
|
+
@thread&.join((ContextFetch::KILL_GRACE_SECONDS * 3) + @tick)
|
|
53
|
+
@thread = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One pass (the thread's loop body; specs call it).
|
|
57
|
+
# @param first [Boolean] the worker's first pass
|
|
58
|
+
def poll(first: false)
|
|
59
|
+
ContextSources.attached(@session_id, project_root: @project_root, state_dir: @state_dir).each do |attached|
|
|
60
|
+
break if @stopping
|
|
61
|
+
next if attached.source.push? || @own.muted?(attached.name)
|
|
62
|
+
|
|
63
|
+
interval = first ? ContextSources::MIN_EVERY_SECONDS : interval_of(attached.source)
|
|
64
|
+
age = attached.snapshot.age
|
|
65
|
+
next if age && age < interval
|
|
66
|
+
|
|
67
|
+
outcome = ContextFetch.fetch(attached, cwd: cwd_for(attached), fresh_within: interval, cancelled: -> { @stopping })
|
|
68
|
+
@on_change.call if %i[new error].include?(outcome.status)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
Log.exception(:context, "poll_failed", e, name: attached.name)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def run
|
|
77
|
+
first = true
|
|
78
|
+
until @stopping
|
|
79
|
+
poll(first: first)
|
|
80
|
+
first = false
|
|
81
|
+
@queue.pop(timeout: @tick)
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
Log.exception(:context, "poller_crashed", e)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def interval_of(source)
|
|
88
|
+
seconds = source.every_seconds || Config.get("context.every_seconds").to_i
|
|
89
|
+
[seconds, ContextSources::MIN_EVERY_SECONDS].max
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A session's source runs in the session's folder, a project's in the
|
|
93
|
+
# project root (the session's folder when the root is no checkout).
|
|
94
|
+
def cwd_for(attached)
|
|
95
|
+
return @cwd if attached.location.session?
|
|
96
|
+
|
|
97
|
+
ContextSources.project_cwd(@project_root, @cwd)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
autoload :ContextSources, File.expand_path("context_sources", __dir__)
|
|
8
|
+
autoload :Log, File.expand_path("log", __dir__)
|
|
9
|
+
module MemoryBundle
|
|
10
|
+
autoload :Provenance, File.expand_path("memory_bundle/provenance", __dir__)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Declarative context providers (attached context, §3.9): a bundle's
|
|
14
|
+
# manifest maps a URL pattern to a source, so any process (the CLI, the
|
|
15
|
+
# web server, a worker) can attach a URL without loading plugins:
|
|
16
|
+
#
|
|
17
|
+
# context_providers:
|
|
18
|
+
# - match: '\Ahttps://github\.com/([^/]+)/([^/]+)/pull/(\d+)'
|
|
19
|
+
# name: 'pr-\3'
|
|
20
|
+
# cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
|
|
21
|
+
# why: 'GitHub PR'
|
|
22
|
+
# every_seconds: 300
|
|
23
|
+
#
|
|
24
|
+
# The installer records them in the bundle's provenance; .resolve reads
|
|
25
|
+
# them from there. +name+ takes the match's groups (\1…); +cmd+ takes
|
|
26
|
+
# {url}, the matched URL (shell-quoted), when the source is attached, and
|
|
27
|
+
# keeps {bundle_dir}, the installed bundle's folder, and {ruby}, the Ruby
|
|
28
|
+
# chi runs on, for ContextFetch to fill in when it runs: a bundle upgrade
|
|
29
|
+
# moves nothing, and a `ruby` on the PATH may be another one (macOS's 2.6,
|
|
30
|
+
# a launchd PATH).
|
|
31
|
+
module ContextProviders
|
|
32
|
+
# A provider as the manifest declares it.
|
|
33
|
+
Provider = Data.define(:match, :name, :cmd, :why, :every_seconds) do
|
|
34
|
+
def to_h = { "match" => match, "name" => name, "cmd" => cmd, "why" => why, "every_seconds" => every_seconds }.compact
|
|
35
|
+
|
|
36
|
+
def regexp = Regexp.new(match)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# What a URL resolved to: the provider's bundle, and the source's
|
|
40
|
+
# fields (+cmd+ still holding {bundle_dir}).
|
|
41
|
+
Resolved = Data.define(:bundle, :name, :cmd, :why, :hint, :every_seconds)
|
|
42
|
+
|
|
43
|
+
class Invalid < ArgumentError; end
|
|
44
|
+
|
|
45
|
+
BUNDLE_DIR = "{bundle_dir}"
|
|
46
|
+
RUBY = "{ruby}"
|
|
47
|
+
URL = "{url}"
|
|
48
|
+
MIN_EVERY_SECONDS = 30
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# The manifest's context_providers: list.
|
|
53
|
+
# @return [Array<Provider>] [] when absent
|
|
54
|
+
# @raise [Invalid]
|
|
55
|
+
def parse_list(raw)
|
|
56
|
+
return [] if raw.nil?
|
|
57
|
+
raise Invalid, "context_providers: must be a list of mappings" unless raw.is_a?(Array)
|
|
58
|
+
|
|
59
|
+
raw.map { |item| parse(item) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @raise [Invalid]
|
|
63
|
+
def parse(item)
|
|
64
|
+
raise Invalid, "context_providers: each item must be a mapping, not #{item.inspect}" unless item.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
data = item.transform_keys(&:to_s)
|
|
67
|
+
%w[match name cmd].each do |key|
|
|
68
|
+
value = data[key]
|
|
69
|
+
raise Invalid, "context_providers: an item has no #{key}:" unless value.is_a?(String) && !value.strip.empty?
|
|
70
|
+
end
|
|
71
|
+
begin
|
|
72
|
+
Regexp.new(data["match"])
|
|
73
|
+
rescue RegexpError => e
|
|
74
|
+
raise Invalid, "context_providers: match #{data["match"].inspect} isn't a regular expression (#{e.message})"
|
|
75
|
+
end
|
|
76
|
+
Provider.new(match: data["match"], name: data["name"], cmd: data["cmd"], why: data["why"]&.to_s,
|
|
77
|
+
every_seconds: every(data["every_seconds"]))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The source the installed bundles' providers make of +url+: the first
|
|
81
|
+
# bundle (by name) whose provider matches. A bundle whose providers
|
|
82
|
+
# don't parse is skipped (logged).
|
|
83
|
+
# @return [Resolved, nil] nil when none matches
|
|
84
|
+
# @raise [Invalid] the provider makes a name that isn't a source name
|
|
85
|
+
def resolve(url)
|
|
86
|
+
url = url.to_s.strip
|
|
87
|
+
MemoryBundle::Provenance.each_installed do |bundle, data|
|
|
88
|
+
next if data[:error] || !data[:context_providers].is_a?(Array)
|
|
89
|
+
|
|
90
|
+
providers(bundle, data).each do |provider|
|
|
91
|
+
match = provider.regexp.match(url) or next
|
|
92
|
+
return resolved(bundle, provider, match)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Whether an installed bundle declares a provider (the web's "+ URL").
|
|
99
|
+
def any?
|
|
100
|
+
MemoryBundle::Provenance.each_installed.any? do |_bundle, data|
|
|
101
|
+
!data[:error] && data[:context_providers].is_a?(Array) && !data[:context_providers].empty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def providers(bundle, data)
|
|
106
|
+
parse_list(data[:context_providers])
|
|
107
|
+
rescue Invalid => e
|
|
108
|
+
Log.warn(:context, "providers_unreadable", bundle: bundle, msg: e.message)
|
|
109
|
+
[]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def resolved(bundle, provider, match)
|
|
113
|
+
url = match[0]
|
|
114
|
+
name = provider.name.gsub(/\\(\d)/) { match[Regexp.last_match(1).to_i].to_s }
|
|
115
|
+
begin
|
|
116
|
+
ContextSources.check_name!(name)
|
|
117
|
+
rescue ContextSources::Invalid
|
|
118
|
+
raise Invalid, "bundle #{bundle} makes #{name.inspect} of it, which isn't a source name"
|
|
119
|
+
end
|
|
120
|
+
Resolved.new(bundle: bundle, name: name, cmd: provider.cmd.gsub(URL) { Shellwords.escape(url) },
|
|
121
|
+
why: provider.why, hint: url, every_seconds: provider.every_seconds)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# +source+'s command as it runs: {bundle_dir} is its provider's
|
|
125
|
+
# installed folder, once that bundle is there and its scripts are as
|
|
126
|
+
# installed (they run outside the guardrails gate, as a plugin loads:
|
|
127
|
+
# checked the same way).
|
|
128
|
+
# @return [String]
|
|
129
|
+
# @raise [Invalid] the bundle is gone or a script changed
|
|
130
|
+
def command_for(source)
|
|
131
|
+
return source.cmd unless source.provider
|
|
132
|
+
|
|
133
|
+
provenance = MemoryBundle::Provenance.new(name: source.provider)
|
|
134
|
+
data = provenance.read
|
|
135
|
+
raise Invalid, "bundle #{source.provider} isn't installed" unless data
|
|
136
|
+
|
|
137
|
+
scripts = data[:scripts].is_a?(Hash) ? data[:scripts] : {}
|
|
138
|
+
scripts.each do |file, meta|
|
|
139
|
+
path = File.join(provenance.scripts_dir, file.to_s)
|
|
140
|
+
recorded = MemoryBundle::Provenance.recorded_sha(meta.is_a?(Hash) ? meta[:sha256] : nil)
|
|
141
|
+
next if File.file?(path) && MemoryBundle::Provenance.file_sha(path) == recorded
|
|
142
|
+
|
|
143
|
+
raise Invalid, "bundle #{source.provider}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
|
|
144
|
+
end
|
|
145
|
+
source.cmd.gsub(BUNDLE_DIR) { Shellwords.escape(provenance.bundle_dir) }.gsub(RUBY) { Shellwords.escape(RbConfig.ruby) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def every(value)
|
|
149
|
+
return nil if value.nil?
|
|
150
|
+
|
|
151
|
+
seconds = Integer(value.to_s, 10, exception: false)
|
|
152
|
+
unless seconds && seconds >= MIN_EVERY_SECONDS
|
|
153
|
+
raise Invalid, "context_providers: every_seconds is #{value.inspect}; it takes seconds, at least #{MIN_EVERY_SECONDS}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
seconds
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|