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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +218 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +1 -0
  10. data/docs/plugins.md +77 -1
  11. data/docs/releasing.md +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +4 -1
  13. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  16. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  23. data/lib/samagotchi/child_reports.rb +0 -2
  24. data/lib/samagotchi/config.rb +6 -0
  25. data/lib/samagotchi/context_absorber.rb +179 -0
  26. data/lib/samagotchi/context_command.rb +473 -0
  27. data/lib/samagotchi/context_fetch.rb +226 -0
  28. data/lib/samagotchi/context_note.rb +11 -5
  29. data/lib/samagotchi/context_poller.rb +100 -0
  30. data/lib/samagotchi/context_providers.rb +159 -0
  31. data/lib/samagotchi/context_sources.rb +452 -0
  32. data/lib/samagotchi/engine.rb +10 -5
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  37. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  38. data/lib/samagotchi/log_line.rb +1 -1
  39. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  40. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  41. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  42. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  43. data/lib/samagotchi/plugin/context.rb +22 -0
  44. data/lib/samagotchi/question_desk.rb +0 -3
  45. data/lib/samagotchi/session.rb +7 -5
  46. data/lib/samagotchi/session_commands.rb +18 -1
  47. data/lib/samagotchi/session_manager.rb +5 -2
  48. data/lib/samagotchi/session_retention.rb +3 -0
  49. data/lib/samagotchi/steer.rb +12 -3
  50. data/lib/samagotchi/system_prompt.rb +1 -1
  51. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -4
  52. data/lib/samagotchi/terminal_ui/formatting.rb +14 -3
  53. data/lib/samagotchi/terminal_ui.rb +0 -3
  54. data/lib/samagotchi/tool_activity.rb +8 -0
  55. data/lib/samagotchi/tool_declarations.rb +21 -0
  56. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  57. data/lib/samagotchi/tools/builtins.rb +5 -0
  58. data/lib/samagotchi/tools/context_read.rb +163 -0
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +109 -1
  61. data/lib/samagotchi/web/public/app.js +40 -10
  62. data/lib/samagotchi/web/public/composer_size.js +7 -6
  63. data/lib/samagotchi/web/public/context_chips.js +285 -0
  64. data/lib/samagotchi/web/public/data.js +21 -0
  65. data/lib/samagotchi/web/public/index.html +49 -3
  66. data/lib/samagotchi/web/public/notify.js +5 -3
  67. data/lib/samagotchi/web/public/timing.js +7 -3
  68. data/lib/samagotchi/web/public/turn_events.js +8 -2
  69. data/lib/samagotchi/web/public/turn_view.js +4 -1
  70. data/lib/samagotchi/worker.rb +126 -0
  71. 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
- KEYS = %i[kind note_id source from_session from_cwd].freeze
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, content: frame(text, source: source, created_at: created_at,
23
- from_session: from_session, from_cwd: from_cwd) }.compact
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