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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +2 -1
- data/docs/architecture.md +8 -6
- data/docs/cli.md +3 -1
- data/docs/configuration.md +33 -1
- data/docs/context.md +249 -46
- data/docs/guardrails.md +11 -0
- data/docs/internals/engine.md +7 -1
- data/docs/internals/prompt-caching.md +10 -1
- data/docs/internals/remote-sessions.md +4 -1
- data/docs/plugins.md +75 -18
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
- data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +8 -0
- data/lib/samagotchi/context_candidate_choice.rb +154 -0
- data/lib/samagotchi/context_candidates.rb +259 -0
- data/lib/samagotchi/context_candidates_log.rb +90 -0
- data/lib/samagotchi/context_command.rb +65 -17
- data/lib/samagotchi/context_judge.rb +194 -0
- data/lib/samagotchi/context_move.rb +31 -0
- data/lib/samagotchi/context_report_command.rb +161 -0
- data/lib/samagotchi/context_sources.rb +311 -29
- data/lib/samagotchi/engine.rb +189 -32
- data/lib/samagotchi/owner_prompt.rb +39 -0
- data/lib/samagotchi/plugin/api.rb +1 -0
- data/lib/samagotchi/plugin/attached_context.rb +105 -13
- data/lib/samagotchi/plugin/context.rb +12 -1
- data/lib/samagotchi/plugin/peer.rb +53 -0
- data/lib/samagotchi/plugin/sessions.rb +9 -2
- data/lib/samagotchi/prompt_warmup.rb +9 -1
- data/lib/samagotchi/send_command.rb +3 -1
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/contract.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +34 -3
- data/lib/samagotchi/session_access/remote.rb +33 -8
- data/lib/samagotchi/session_access.rb +31 -6
- data/lib/samagotchi/session_commands.rb +28 -5
- data/lib/samagotchi/session_continue.rb +9 -2
- data/lib/samagotchi/side_judgments.rb +84 -0
- data/lib/samagotchi/terminal_ui.rb +15 -0
- data/lib/samagotchi/testing/boundary.rb +5 -1
- data/lib/samagotchi/testing/host.rb +4 -1
- data/lib/samagotchi/toolkit_report_command.rb +2 -1
- data/lib/samagotchi/toolkit_suggest.rb +3 -18
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +24 -11
- data/lib/samagotchi/web/auto_update.rb +8 -1
- data/lib/samagotchi/web/public/app.js +8 -2
- data/lib/samagotchi/web/public/chains.js +10 -2
- data/lib/samagotchi/web/public/update_notice.js +21 -8
- data/lib/samagotchi/web/session_access_routes.rb +8 -3
- data/lib/samagotchi/web/session_create.rb +1 -0
- data/lib/samagotchi/web/session_hub.rb +5 -2
- data/lib/samagotchi/web/update_check.rb +14 -8
- data/lib/samagotchi/worker.rb +5 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
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 }
|