samagotchi 0.139.1 → 0.141.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +2 -1
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +3 -1
  6. data/docs/configuration.md +33 -1
  7. data/docs/context.md +249 -46
  8. data/docs/guardrails.md +11 -0
  9. data/docs/internals/engine.md +7 -1
  10. data/docs/internals/prompt-caching.md +10 -1
  11. data/docs/internals/remote-sessions.md +4 -1
  12. data/docs/plugins.md +75 -18
  13. data/docs/sessions.md +3 -2
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  15. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  21. data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/config.rb +8 -0
  24. data/lib/samagotchi/context_candidate_choice.rb +154 -0
  25. data/lib/samagotchi/context_candidates.rb +259 -0
  26. data/lib/samagotchi/context_candidates_log.rb +90 -0
  27. data/lib/samagotchi/context_command.rb +65 -17
  28. data/lib/samagotchi/context_judge.rb +194 -0
  29. data/lib/samagotchi/context_move.rb +31 -0
  30. data/lib/samagotchi/context_report_command.rb +161 -0
  31. data/lib/samagotchi/context_sources.rb +311 -29
  32. data/lib/samagotchi/engine.rb +189 -32
  33. data/lib/samagotchi/owner_prompt.rb +39 -0
  34. data/lib/samagotchi/plugin/api.rb +1 -0
  35. data/lib/samagotchi/plugin/attached_context.rb +105 -13
  36. data/lib/samagotchi/plugin/context.rb +12 -1
  37. data/lib/samagotchi/plugin/peer.rb +53 -0
  38. data/lib/samagotchi/plugin/sessions.rb +9 -2
  39. data/lib/samagotchi/prompt_warmup.rb +9 -1
  40. data/lib/samagotchi/send_command.rb +3 -1
  41. data/lib/samagotchi/session.rb +4 -0
  42. data/lib/samagotchi/session_access/contract.rb +4 -0
  43. data/lib/samagotchi/session_access/local.rb +34 -3
  44. data/lib/samagotchi/session_access/remote.rb +33 -8
  45. data/lib/samagotchi/session_access.rb +31 -6
  46. data/lib/samagotchi/session_commands.rb +28 -5
  47. data/lib/samagotchi/session_continue.rb +9 -2
  48. data/lib/samagotchi/side_judgments.rb +84 -0
  49. data/lib/samagotchi/terminal_ui.rb +15 -0
  50. data/lib/samagotchi/testing/boundary.rb +5 -1
  51. data/lib/samagotchi/testing/host.rb +4 -1
  52. data/lib/samagotchi/toolkit_report_command.rb +2 -1
  53. data/lib/samagotchi/toolkit_suggest.rb +3 -18
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +24 -11
  56. data/lib/samagotchi/web/auto_update.rb +8 -1
  57. data/lib/samagotchi/web/public/app.js +8 -2
  58. data/lib/samagotchi/web/public/chains.js +10 -2
  59. data/lib/samagotchi/web/public/update_notice.js +21 -8
  60. data/lib/samagotchi/web/session_access_routes.rb +8 -3
  61. data/lib/samagotchi/web/session_create.rb +1 -0
  62. data/lib/samagotchi/web/session_hub.rb +5 -2
  63. data/lib/samagotchi/web/update_check.rb +14 -8
  64. data/lib/samagotchi/worker.rb +5 -0
  65. metadata +9 -1
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "context_absorber"
5
+ require_relative "context_candidates_log"
6
+ require_relative "context_fetch"
7
+ require_relative "context_providers"
8
+ require_relative "context_sources"
9
+ require_relative "log"
10
+ require_relative "owner_prompt"
11
+
12
+ module Samagotchi
13
+ # A session's pool of candidate context (ContextSources::Candidate, from
14
+ # ctx.context.propose) and the one thing that attaches one in slice 1:
15
+ # the owner mentioning it. A candidate's status is derived, never stored:
16
+ # attached while a source of its name (or its URL) is attached, declined
17
+ # while the user's declined marker names it (or its URL), else proposed.
18
+ #
19
+ # Mentions (#mention_in): whole tokens, any case; a "#N" only for N ≥ 10
20
+ # or right after "PR" / "pull" ("PR #4"); any other word (a branch) only
21
+ # when it is at least 6 characters and holds a /, - or _; a URL exactly.
22
+ # Text inside fenced code blocks (pasted logs) never counts.
23
+ #
24
+ # Only the owner's own prompts count (OwnerPrompt), and nothing happens in
25
+ # a review session: a participant never attaches a source to the owner's
26
+ # session.
27
+ module ContextCandidates
28
+ STATUSES = %w[proposed attached declined].freeze
29
+ # How long a turn waits for a mention-attached source's first text, so
30
+ # its note lands in the turn that mentioned it; past it the note comes
31
+ # with a later turn.
32
+ FIRST_FETCH_SECONDS = 10
33
+ BRANCH_MIN_CHARS = 6
34
+ NUMBER_MIN = 10
35
+ # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
36
+ FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
37
+ # What may sit around a word-like mention without joining it.
38
+ WORD_BEFORE = %r{(?<![\w./#-])}
39
+ WORD_AFTER = %r{(?![\w/-])(?!\.[\w/-])}
40
+ # A candidate attached from a mention, and the mention.
41
+ Hit = Data.define(:candidate, :mention)
42
+
43
+ module_function
44
+
45
+ # @param attached [Array<ContextSources::Attached>] what the session sees
46
+ # @return [String] "attached", "declined" or "proposed"
47
+ def status(candidate, own:, attached:)
48
+ if attached.any? { |a| a.name == candidate.name || (candidate.url && a.source.hint == candidate.url) }
49
+ return "attached"
50
+ end
51
+ return "declined" if own.declined?(candidate.name) || (candidate.url && own.declined_hints.include?(candidate.url))
52
+
53
+ "proposed"
54
+ end
55
+
56
+ # The session's candidates with their status, by name.
57
+ # @return [Array<Array(ContextSources::Candidate, String)>]
58
+ def with_status(session_id, project_root:, state_dir: nil)
59
+ own = ContextSources.session_location(session_id, state_dir: state_dir)
60
+ candidates = own.candidates
61
+ return [] if candidates.empty?
62
+
63
+ attached = ContextSources.attached(session_id, project_root: project_root, state_dir: state_dir, shadowed: true)
64
+ candidates.map { |candidate| [candidate, status(candidate, own: own, attached: attached)] }
65
+ end
66
+
67
+ # The candidates section of /context, nil for none.
68
+ def listing(session_id, project_root:, state_dir: nil)
69
+ rows = with_status(session_id, project_root: project_root, state_dir: state_dir)
70
+ return nil if rows.empty?
71
+
72
+ lines = rows.map { |candidate, state| "- #{candidate.name} · #{state} · #{candidate.title}" }
73
+ "Candidates (attached when a prompt of yours mentions one, or by /context attach NAME):\n#{lines.join("\n")}"
74
+ end
75
+
76
+ # The words in +text+ that name +candidate+: its first mention found,
77
+ # or nil.
78
+ def mention_in(text, candidate)
79
+ plain = without_fences(text.to_s)
80
+ return nil if plain.strip.empty?
81
+
82
+ candidate.mentions.find { |mention| (pattern = pattern_for(mention)) && plain.match?(pattern) }
83
+ end
84
+
85
+ # The regexp a mention matches by, nil for one that never matches (a
86
+ # short or plain word).
87
+ def pattern_for(mention)
88
+ mention = mention.to_s.strip
89
+ if (number = mention[/\A#(\d+)\z/, 1])
90
+ return /(?<![\w&#])##{number}(?!\w)/ if number.to_i >= NUMBER_MIN
91
+
92
+ return /\b(?:pr|pull(?:\s+request)?)\s*##{number}(?!\w)/i
93
+ end
94
+ return %r{(?<![\w/.:-])#{Regexp.escape(mention)}(?![\w/#?=&%-])} if mention.match?(%r{\Ahttps?://}i)
95
+ return nil if mention.length < BRANCH_MIN_CHARS || !mention.match?(%r{[/_-]})
96
+
97
+ /#{WORD_BEFORE}#{Regexp.escape(mention)}#{WORD_AFTER}/i
98
+ end
99
+
100
+ # +text+ with its fenced code blocks blanked out.
101
+ def without_fences(text)
102
+ out = +""
103
+ open = nil
104
+ text.each_line do |line|
105
+ marker = line[FENCE_OPEN, 1]
106
+ if open.nil? && marker
107
+ open = marker
108
+ elsif open && marker && marker[0] == open[0] && marker.length >= open.length && line.strip == marker
109
+ open = nil
110
+ elsif open.nil?
111
+ out << line
112
+ end
113
+ end
114
+ out
115
+ end
116
+
117
+ # The owner's own prompts in +messages+ (OwnerPrompt.message?), as text.
118
+ def owner_texts(messages)
119
+ Array(messages).filter_map do |message|
120
+ next unless OwnerPrompt.message?(message)
121
+
122
+ content = message.is_a?(Hash) ? (message[:content] || message["content"]) : nil
123
+ content.is_a?(String) ? content : nil
124
+ end
125
+ end
126
+
127
+ # The proposed candidates +texts+ mention (the first text that does,
128
+ # newest first), one hit each.
129
+ # @return [Array<Hit>]
130
+ def hits(texts, candidates)
131
+ candidates.filter_map do |candidate|
132
+ mention = texts.reverse_each.lazy.filter_map { |text| mention_in(text, candidate) }.first
133
+ mention && Hit.new(candidate: candidate, mention: mention)
134
+ end
135
+ end
136
+
137
+ # Attaches +hit+'s candidate to the session's own folder, its why naming
138
+ # the mention (a mentioned line in ContextCandidatesLog, in +state_dir+'s
139
+ # log). A source of its name there already stays.
140
+ # @return [String, nil] the name, nil when it couldn't be attached
141
+ def attach(hit, own:, state_dir: nil)
142
+ candidate = hit.candidate
143
+ own.add(source_for(candidate, why: "you mentioned #{hit.mention}"))
144
+ Log.info(:context, "candidate_attached", name: candidate.name, trigger: "mention")
145
+ ContextCandidatesLog.event("mentioned", session_id: own.key, name: candidate.name, state_dir: state_dir,
146
+ mention: hit.mention)
147
+ candidate.name
148
+ rescue ContextSources::Invalid, ContextProviders::Invalid => e
149
+ return candidate.name if own.source(candidate.name)
150
+
151
+ Log.warn(:context, "candidate_not_attached", name: candidate.name, error: e.message.to_s[0, 200])
152
+ nil
153
+ end
154
+
155
+ # The Source a candidate becomes: through its URL's provider, else its
156
+ # own command.
157
+ # @raise [ContextProviders::Invalid] no installed bundle resolves its URL
158
+ def source_for(candidate, why:)
159
+ fields = { name: candidate.name, why: why, scope: "session", added_by: candidate.proposed_by,
160
+ created_at: Time.now.utc.iso8601, title: candidate.title }
161
+ if candidate.url
162
+ resolved = ContextProviders.resolve(candidate.url) or
163
+ raise ContextProviders::Invalid, "no installed bundle resolves #{candidate.url}"
164
+ return ContextSources::Source.new(cmd: resolved.cmd, hint: resolved.hint, every_seconds: resolved.every_seconds,
165
+ provider: resolved.bundle, **fields)
166
+ end
167
+ ContextSources::Source.new(cmd: candidate.cmd, hint: nil, every_seconds: nil, provider: nil, **fields)
168
+ end
169
+
170
+ # The proposed candidates (no source of theirs attached, not declined).
171
+ def proposed(session_id, project_root:, state_dir: nil)
172
+ with_status(session_id, project_root: project_root, state_dir: state_dir)
173
+ .filter_map { |candidate, state| candidate if state == "proposed" }
174
+ end
175
+
176
+ # Turn start (Worker#run_prompt before its absorb; the REPL's turn):
177
+ # the proposed candidates +prompt+ (or an earlier owner prompt: a late
178
+ # proposal) mentions are attached, then each gets one first fetch
179
+ # (together within +seconds+), so the note reaches this turn. Only an
180
+ # owner's turn (OwnerPrompt.turn?). Also fetches a candidate attached
181
+ # since the last turn whose source has never been tried.
182
+ # @param session [Session]
183
+ # @param cancelled [#call] true: stop waiting (the session was stopped)
184
+ # @return [Array<String>] the names attached now
185
+ def before_turn(session:, prompt:, author:, state_dir: nil, seconds: FIRST_FETCH_SECONDS, cancelled: -> { false })
186
+ return [] unless session&.id
187
+
188
+ own = ContextSources.session_location(session.id, state_dir: state_dir)
189
+ return [] if own.candidates.empty? || !OwnerPrompt.turn?(session: session, author: author)
190
+
191
+ # The earlier owner prompts too: a proposal that came while one ran
192
+ # (the REPL's plugins never see a running turn's prompt).
193
+ texts = owner_texts(session.messages) + (prompt.is_a?(String) ? [prompt] : [])
194
+ found = hits(texts, proposed(session.id, project_root: session.project_root, state_dir: state_dir))
195
+ names = found.filter_map { |hit| attach(hit, own: own, state_dir: state_dir) }
196
+ first_fetch(own, unfetched(own, names), cwd: session.working_directory, seconds: seconds, cancelled: cancelled)
197
+ names
198
+ rescue StandardError => e
199
+ Log.warn(:context, "candidates_turn_failed", error: e.class.name, msg: e.message.to_s[0, 200])
200
+ []
201
+ end
202
+
203
+ # The REPL's turn start (it has no ContextAbsorber running): the notes
204
+ # of the sources attached from candidates, as the worker's absorb gives
205
+ # them, added to +engine+'s session and marked delivered.
206
+ # @return [Integer] how many notes were added
207
+ def deliver_notes(engine, session, state_dir: nil)
208
+ own = ContextSources.session_location(session.id, state_dir: state_dir)
209
+ names = own.candidates.map(&:name)
210
+ return 0 if names.empty?
211
+
212
+ absorber = ContextAbsorber.new(session_id: session.id, state_dir: state_dir, project_root: session.project_root)
213
+ batch = absorber.pending or return 0
214
+ picked = batch.deliveries.select { |delivery| names.include?(delivery.name) }
215
+ picked.each { |delivery| engine.add_context_note(session, delivery.note) if delivery.note }
216
+ absorber.commit(batch.with(deliveries: picked))
217
+ picked.count(&:note)
218
+ end
219
+
220
+ # A late proposal (ctx.context.propose, after the owner's prompts):
221
+ # attached now when one of them mentions it; the session's next turn
222
+ # delivers its note.
223
+ # @return [String, nil] the name attached
224
+ def after_propose(candidate, own:, messages:, state_dir: nil)
225
+ hit = hits(owner_texts(messages), [candidate]).first or return nil
226
+
227
+ attach(hit, own: own, state_dir: state_dir)
228
+ end
229
+
230
+ # The session sources from candidates (+names+, and any other
231
+ # candidate's source) never tried yet.
232
+ def unfetched(own, names)
233
+ wanted = (names + own.candidates.map(&:name)).uniq
234
+ wanted.filter_map do |name|
235
+ source = own.source(name) or next
236
+ next if source.push? || own.snapshot(name).checked_at
237
+
238
+ ContextSources::Attached.new(source: source, location: own, shadowed: false)
239
+ end
240
+ end
241
+
242
+ # Runs each source's first fetch, all within +seconds+; one a poller
243
+ # fetches meanwhile (:busy) is waited for. Cut short, a fetch records
244
+ # nothing (the poller tries it soon); the note then comes later.
245
+ def first_fetch(own, list, cwd:, seconds:, cancelled:)
246
+ return if list.empty?
247
+
248
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
249
+ over = -> { cancelled.call || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline }
250
+ list.each do |attached|
251
+ outcome = ContextFetch.fetch(attached, cwd: cwd, cancelled: over)
252
+ sleep(0.05) while outcome.status == :busy && !own.snapshot(attached.name).checked_at && !over.call
253
+ next unless over.call && !own.snapshot(attached.name).text?
254
+
255
+ Log.info(:context, "candidate_first_fetch_late", name: attached.name, seconds: seconds)
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log"
4
+ require_relative "session"
5
+ require_relative "side_judgments"
6
+
7
+ module Samagotchi
8
+ # The candidates' record, `context_candidates.jsonl` in the state dir
9
+ # (beside the sessions folder; SideJudgments.log: 0600, one line under a
10
+ # lock, the oldest half dropped past 5 MB): what happened to each
11
+ # session's candidates, for `chi context report` (ContextReportCommand).
12
+ # Local only: nothing here leaves the machine, so it is written in every
13
+ # context.candidates.mode, off too.
14
+ #
15
+ # Kinds (every line has session_id, name and at):
16
+ # - proposed: a plugin's ctx.context.propose recorded it (title, by)
17
+ # - mentioned: an owner prompt named it and it was attached (mention)
18
+ # - judged: System 1's ask (ContextJudge.ask: p, task_p, stated, would, …)
19
+ # - judge_attached: on mode attached it (p ≥ threshold; p)
20
+ # - carded: on mode showed its card (the middle band; p): once per
21
+ # candidate, read back by ContextCandidateChoice.carded?
22
+ # - attached / declined with by "command": /context attach|decline NAME
23
+ # (the card's buttons)
24
+ # - attached / declined / detached with by "user": the derived status
25
+ # changed since the last line said otherwise, seen at an owner turn's
26
+ # start (#look): the user's own doing (chi context add, chi web's chip
27
+ # ×, chi context rm; a removed source is declined)
28
+ # - withdrawn: the plugin took the candidate back
29
+ module ContextCandidatesLog
30
+ FILE = "context_candidates.jsonl"
31
+ # What a logged prompt keeps of its text.
32
+ PROMPT_MAX = 200
33
+ # The status each kind leaves a candidate in (#statuses).
34
+ STATUS_OF = { "proposed" => "proposed", "mentioned" => "attached", "judge_attached" => "attached", "attached" => "attached",
35
+ "declined" => "declined", "detached" => "proposed", "withdrawn" => nil }.freeze
36
+
37
+ module_function
38
+
39
+ # @param state_dir [String, nil] the sessions folder (Session.default_state_dir)
40
+ def path(state_dir = nil) = File.join(File.dirname(state_dir || Session.default_state_dir), FILE)
41
+
42
+ # @return [SystemOne::Log]
43
+ def log_for(state_dir = nil) = SideJudgments.log(path(state_dir), tag: :context, event: "context_candidates_log_failed")
44
+
45
+ # One line; never raises.
46
+ # @return [Boolean] whether it was written
47
+ def event(kind, session_id:, name:, state_dir: nil, log: nil, **fields)
48
+ (log || log_for(state_dir)).entry(kind, { "session_id" => session_id.to_s, "name" => name.to_s }.merge(fields.compact))
49
+ rescue StandardError => e
50
+ Log.warn(:context, "context_candidates_log_failed", error: e.class.name, msg: e.message.to_s[0, 200])
51
+ false
52
+ end
53
+
54
+ # +session_id+'s lines, oldest first.
55
+ def records_for(log, session_id) = log.records.select { |line| line["session_id"] == session_id.to_s }
56
+
57
+ # The status the lines last left each candidate in, by name (a
58
+ # withdrawn one is gone).
59
+ # @return [Hash{String => String}]
60
+ def statuses(records)
61
+ records.each_with_object({}) do |line, known|
62
+ next unless STATUS_OF.key?(line["kind"])
63
+
64
+ status = STATUS_OF[line["kind"]]
65
+ status ? known[line["name"]] = status : known.delete(line["name"])
66
+ end
67
+ end
68
+
69
+ # The derived statuses now (+rows+: ContextCandidates.with_status)
70
+ # against the last ones logged: each change is a line (attached,
71
+ # declined, detached). A candidate never logged counts as proposed.
72
+ # @return [Array<String>] the kinds written
73
+ def look(log, session_id, rows, records)
74
+ known = statuses(records)
75
+ rows.filter_map do |candidate, status|
76
+ next if status == (known[candidate.name] || "proposed")
77
+
78
+ kind = status == "proposed" ? "detached" : status
79
+ event(kind, session_id: session_id, name: candidate.name, log: log, by: "user")
80
+ kind
81
+ end
82
+ end
83
+
84
+ # +text+ as a log line keeps it: one line, at most PROMPT_MAX chars.
85
+ def short(text)
86
+ line = text.to_s.gsub(/\s+/, " ").strip
87
+ line.length > PROMPT_MAX ? "#{line[0, PROMPT_MAX - 1]}…" : line
88
+ end
89
+ end
90
+ end
@@ -5,6 +5,7 @@ require "time"
5
5
  require_relative "context_sources"
6
6
  require_relative "context_fetch"
7
7
  require_relative "context_providers"
8
+ require_relative "context_report_command"
8
9
  require_relative "memory_paths"
9
10
  require_relative "session"
10
11
  require_relative "cli/command"
@@ -23,25 +24,28 @@ module Samagotchi
23
24
  include RedactedInspect
24
25
 
25
26
  USAGE = <<~TEXT
26
- Usage: chi context <add|push|ls|show|refresh|rm|mute|unmute> [options] [TARGET]
27
- add NAME (--cmd CMD | --push) [--every SECONDS] [--why TEXT] [--hint TEXT] TARGET
28
- add URL [--why TEXT] [--hint TEXT] TARGET a URL an installed bundle's provider knows (github-pr: a PR)
27
+ Usage: chi context <add|push|ls|show|refresh|rm|mute|unmute|report> [options] [TARGET]
28
+ add NAME (--cmd CMD | --push) [--every SECONDS] [--why TEXT] [--hint TEXT] [--[no-]movable] TARGET
29
+ add URL [--why TEXT] [--hint TEXT] [--[no-]movable] TARGET a URL an installed bundle's provider knows (github-pr: a PR)
29
30
  a source: CMD prints its text (plain, or JSON {"text", "summary", "wake", "hint"});
30
31
  --push: text comes from chi context push. --every: how often CMD runs
31
32
  (seconds, at least 30; default context.every_seconds). --why: why it's attached;
32
33
  --hint: one line, often its URL. NAME: a-z, 0-9 and -, up to 40.
34
+ --movable / --no-movable: whether a continue or a fork takes it to the new
35
+ session (default: a --cmd or URL source moves, a --push one stays).
33
36
  push NAME [-m TEXT] [TARGET] new text for a --push source (stdin without -m; text or JSON)
34
- ls [TARGET] [--format json] the sources, their age and state
37
+ ls [TARGET] [--format json] the sources, their age and state; a session's candidates
35
38
  show NAME [--json] [TARGET] the text (--json: the source and its snapshot)
36
39
  refresh NAME [TARGET] run NAME's command now, here (a live worker absorbs the result)
37
40
  rm NAME TARGET detach a source
38
41
  mute|unmute NAME ID... a project's source, ignored by one session
42
+ report [--days N] the candidate judge's counts (this machine's log)
39
43
  TARGET: session ids or unique prefixes, or --project (this git repository's
40
44
  sessions, all of them). Inside a chi session (its execute) the default is that
41
45
  session. Find ids with: chi sessions list [--live]
42
46
  TEXT
43
47
 
44
- SUBCOMMANDS = %w[add push ls show refresh rm mute unmute].freeze
48
+ SUBCOMMANDS = %w[add push ls show refresh rm mute unmute report].freeze
45
49
 
46
50
  FLAGS = {
47
51
  "add" => CLI::Flags.new(help: HELP_WORDS) do |f|
@@ -50,6 +54,8 @@ module Samagotchi
50
54
  f.value "--every"
51
55
  f.value "--why"
52
56
  f.value "--hint"
57
+ f.switch "--movable"
58
+ f.switch "--no-movable", key: :movable, set: false
53
59
  f.switch "--project"
54
60
  end,
55
61
  "push" => CLI::Flags.new(help: HELP_WORDS) do |f|
@@ -67,7 +73,8 @@ module Samagotchi
67
73
  "refresh" => CLI::Flags.new(help: HELP_WORDS) { |f| f.switch "--project" },
68
74
  "rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.switch "--project" },
69
75
  "mute" => CLI::Flags.new(help: HELP_WORDS),
70
- "unmute" => CLI::Flags.new(help: HELP_WORDS)
76
+ "unmute" => CLI::Flags.new(help: HELP_WORDS),
77
+ "report" => ContextReportCommand::FLAGS
71
78
  }.freeze
72
79
 
73
80
  # Where a command acts: a session (its own sources, then its project's)
@@ -106,6 +113,10 @@ module Samagotchi
106
113
  return 0
107
114
  end
108
115
  return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
116
+ # Its own command (its own flags and usage): this machine's log.
117
+ if @sub == "report"
118
+ return ContextReportCommand.new(@argv[1..], stdout: @stdout, stderr: @stderr, state_dir: @state_dir).run
119
+ end
109
120
 
110
121
  parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
111
122
  return parsed if parsed.is_a?(Integer)
@@ -135,15 +146,27 @@ module Samagotchi
135
146
  name: name, cmd: options[:cmd], every_seconds: ContextSources.check_every!(options[:every]),
136
147
  why: ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS),
137
148
  hint: ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS),
138
- scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601
149
+ scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, movable: options[:movable]
139
150
  )
140
151
  each_target(options, args) do |target|
141
- @access.context_add(target, source)
152
+ add_to(target, source)
142
153
  @stdout.puts("#{target.label} attached #{name}")
143
154
  true
144
155
  end
145
156
  end
146
157
 
158
+ # Attaches +source+ to +target+; a movable flag an older chi web
159
+ # dropped (its answer has no movable) is a warning: the source is
160
+ # attached with the default.
161
+ def add_to(target, source)
162
+ warn_movable_dropped(target, source.name) if @access.context_add(target, source).movable_dropped
163
+ end
164
+
165
+ def warn_movable_dropped(target, name)
166
+ error_line("#{command_name}: warning: the remote's chi web is too old for --movable/--no-movable: " \
167
+ "#{name} on #{target.label} has the default (update chi there)")
168
+ end
169
+
147
170
  # A URL through the installed bundles' providers (ContextProviders):
148
171
  # the provider gives the name, command, hint and interval. Not gated
149
172
  # like --cmd (chi-context-cmd): the command is the bundle's.
@@ -160,10 +183,11 @@ module Samagotchi
160
183
  name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds,
161
184
  why: ContextSources.one_line(options[:why] || resolved.why, ContextSources::LINE_MAX_CHARS),
162
185
  hint: ContextSources.one_line(options[:hint] || resolved.hint, ContextSources::LINE_MAX_CHARS),
163
- scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, provider: resolved.bundle
186
+ scope: nil, added_by: added_by, created_at: Time.now.utc.iso8601, provider: resolved.bundle,
187
+ movable: options[:movable]
164
188
  )
165
189
  each_target(options, args) do |target|
166
- @access.context_add(target, source)
190
+ add_to(target, source)
167
191
  @stdout.puts("#{target.label} attached #{source.name}")
168
192
  true
169
193
  end
@@ -176,8 +200,9 @@ module Samagotchi
176
200
  why = ContextSources.one_line(options[:why], ContextSources::LINE_MAX_CHARS)
177
201
  hint = ContextSources.one_line(options[:hint], ContextSources::LINE_MAX_CHARS)
178
202
  each_target(options, args) do |target|
179
- name = @access.context_add_url(target, url, why: why, hint: hint, added_by: added_by)
180
- @stdout.puts("#{target.label} attached #{name}")
203
+ added = @access.context_add_url(target, url, why: why, hint: hint, added_by: added_by, movable: options[:movable])
204
+ warn_movable_dropped(target, added.name) if added.movable_dropped
205
+ @stdout.puts("#{target.label} attached #{added.name}")
181
206
  true
182
207
  end
183
208
  rescue ContextProviders::Invalid => e
@@ -203,6 +228,9 @@ module Samagotchi
203
228
  # two fetches would read as a change and then vanish.
204
229
  error_line("#{command_name}: #{SessionAccess::ContextPush.not_push_message(name)}")
205
230
  next false
231
+ when :continued
232
+ error_line("#{command_name}: #{SessionAccess::ContextPush.continued_message(name, target.session_id, pushed.next_id)}")
233
+ next false
206
234
  end
207
235
 
208
236
  @stdout.puts("#{target.label} #{name}: #{pushed.revision == pushed.before ? "unchanged" : "new text"} " \
@@ -217,16 +245,34 @@ module Samagotchi
217
245
 
218
246
  targets = targets(options, args) or return CLI::Exit::FAILED
219
247
  rows = targets.flat_map { |target| @access.context_entries(target).map { |entry| row(target, entry) } }
248
+ candidates = targets.flat_map { |target| @access.context_candidates(target).map { |entry| candidate_row(target, entry) } }
220
249
  if format == "json"
221
- @stdout.puts(JSON.pretty_generate(rows))
222
- elsif rows.empty?
223
- @stdout.puts("no attached context")
250
+ @stdout.puts(JSON.pretty_generate(rows + candidates))
224
251
  else
225
- print_rows(rows, labels: targets.size > 1)
252
+ rows.empty? ? @stdout.puts("no attached context") : print_rows(rows, labels: targets.size > 1)
253
+ print_candidates(candidates, labels: targets.size > 1)
226
254
  end
227
255
  0
228
256
  end
229
257
 
258
+ # A candidate's line of ls (its --format json object, marked candidate).
259
+ # @param entry [SessionAccess::ContextCandidate]
260
+ def candidate_row(target, entry)
261
+ candidate = entry.candidate
262
+ { "target" => target.label, "session_id" => target.session_id, "candidate" => true, "name" => candidate.name,
263
+ "status" => entry.status, "title" => candidate.title, "url" => candidate.url, "cmd" => candidate.cmd,
264
+ "mentions" => candidate.mentions, "proposed_by" => candidate.proposed_by, "at" => candidate.at }.compact
265
+ end
266
+
267
+ def print_candidates(rows, labels:)
268
+ return if rows.empty?
269
+
270
+ @stdout.puts("candidates (attached when the session's owner mentions one):")
271
+ lines = rows.map { |r| [*(labels ? [r["target"]] : []), r["name"], r["status"], r["title"].to_s] }
272
+ widths = lines.transpose.map { |column| column.map(&:length).max }
273
+ lines.each { |line| @stdout.puts(" #{line.zip(widths).map { |cell, w| cell.ljust(w) }.join(" ").rstrip}") }
274
+ end
275
+
230
276
  def run_show(options, args)
231
277
  name = args.shift
232
278
  return usage_error("give the source's NAME") unless name
@@ -427,7 +473,8 @@ module Samagotchi
427
473
  "cmd" => source.cmd, "every_seconds" => source.every_seconds, "why" => source.why, "hint" => source.hint,
428
474
  "added_by" => source.added_by, "created_at" => source.created_at, "fetched_at" => snapshot.fetched_at,
429
475
  "revision" => snapshot.revision, "summary" => snapshot.summary, "error" => snapshot.error,
430
- "muted" => entry.muted, "shadowed" => entry.shadowed, "unread" => entry.unread }.compact
476
+ "muted" => entry.muted, "shadowed" => entry.shadowed, "unread" => entry.unread,
477
+ "movable" => (source.movable? if source.scope == "session") }.compact
431
478
  end
432
479
 
433
480
  def print_rows(rows, labels:)
@@ -440,6 +487,7 @@ module Samagotchi
440
487
  else
441
488
  "ok"
442
489
  end
490
+ state = "#{state} · stays" if r["movable"] == false
443
491
  [*(labels ? [r["target"]] : []), r["name"], r["scope"], kind, age(r["fetched_at"]), state, r["why"].to_s]
444
492
  end
445
493
  widths = lines.transpose.map { |column| column.map(&:length).max }