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,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "context_candidates_log"
|
|
5
|
+
require_relative "log"
|
|
6
|
+
require_relative "system_one/question"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# System 1's judgment of a session's candidate context
|
|
10
|
+
# (docs/context.md, "The judge"): is this session's work about the
|
|
11
|
+
# candidate? One ask per candidate, its state neutral and small: the
|
|
12
|
+
# candidate's title (and describe), the owner's own prompts (the first
|
|
13
|
+
# substantive one and the latest, each bounded) and, for a delegate
|
|
14
|
+
# child, what its parent session has attached. Never the branch or the
|
|
15
|
+
# checkout (bare place facts), never the source's why, never "found for
|
|
16
|
+
# this checkout". The instructions are constant (one for a session, one
|
|
17
|
+
# for a delegate child): the candidate is in the state, so the server's
|
|
18
|
+
# prefix cache holds and nothing a plugin wrote is read as instructions.
|
|
19
|
+
#
|
|
20
|
+
# The same ask has a second question, "Has the user stated a task yet?":
|
|
21
|
+
# under TASK_MIN the candidate stays unjudged and is asked again at the
|
|
22
|
+
# next owner prompt. After a judged ask, one re-ask once
|
|
23
|
+
# context.candidates.reask_after_turns more owner prompts came, then
|
|
24
|
+
# done (#due?). What it asked and answered is a `judged` line in
|
|
25
|
+
# ContextCandidatesLog. In on mode ContextCandidateChoice acts on it
|
|
26
|
+
# (attach, card, or nothing); audit only logs.
|
|
27
|
+
module ContextJudge
|
|
28
|
+
MODES = %w[off audit on].freeze
|
|
29
|
+
ABOUT_ID = "about"
|
|
30
|
+
TASK_ID = "task"
|
|
31
|
+
# The bound on each prompt in the state.
|
|
32
|
+
PROMPT_MAX = 1500
|
|
33
|
+
# Below this the user hasn't stated a task yet: the ask is deferred.
|
|
34
|
+
TASK_MIN = 0.5
|
|
35
|
+
# A prompt of this many words or fewer is not the first substantive one
|
|
36
|
+
# while a longer one follows (#conversation).
|
|
37
|
+
SHORT_WORDS = 3
|
|
38
|
+
ABOUT = "A user works with a coding agent session. The state names a candidate source of context and the " \
|
|
39
|
+
"user's messages in the session so far (the first one with a task, and the latest). " \
|
|
40
|
+
"Is this session's work about the candidate? Work on something else is not."
|
|
41
|
+
CHILD = " This session was started by another agent session: the conversation's first message is that " \
|
|
42
|
+
"session's request, and started_from lists what that session has attached (the work it is doing)."
|
|
43
|
+
TASK = "A user works with a coding agent session; the state holds the user's messages so far. " \
|
|
44
|
+
"Has the user stated a task yet? A greeting or small talk alone is not one."
|
|
45
|
+
|
|
46
|
+
# One ask's snapshot, taken on the turn's (or the plugin's) thread.
|
|
47
|
+
# +conversation+ is the owner's prompts as the state carries them,
|
|
48
|
+
# +parent_attached+ nil unless the session is a delegate child,
|
|
49
|
+
# +owner_prompts+ how many owner prompts the session had (the re-ask
|
|
50
|
+
# counts them), +phase+ "turn" (a turn's start or end) or "late" (a
|
|
51
|
+
# proposal after the prompts).
|
|
52
|
+
Job = Data.define(:session_id, :name, :title, :describe, :conversation, :parent_attached, :owner_prompts, :phase)
|
|
53
|
+
# The thresholds a judged line's would is read with, and the mode.
|
|
54
|
+
Settings = Data.define(:mode, :threshold, :offer_threshold, :reask_after_turns) do
|
|
55
|
+
def self.from_config
|
|
56
|
+
new(mode: ContextJudge.mode, threshold: Config.get("context.candidates.threshold").to_f,
|
|
57
|
+
offer_threshold: Config.get("context.candidates.offer_threshold").to_f,
|
|
58
|
+
reask_after_turns: [Config.get("context.candidates.reask_after_turns").to_i, 1].max)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# context.candidates.mode: off (the default: no conversation text goes
|
|
65
|
+
# to a System 1 host), audit or on. An unknown value reads as off.
|
|
66
|
+
def mode
|
|
67
|
+
value = Config.get("context.candidates.mode").to_s.strip.downcase
|
|
68
|
+
MODES.include?(value) ? value : "off"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The bounded conversation the state carries: the first substantive
|
|
72
|
+
# owner prompt and the latest (one when they are the same). +skip+
|
|
73
|
+
# owner prompts at the start are not substantive (an earlier ask said
|
|
74
|
+
# no task was stated in them), nor is a short one (SHORT_WORDS words or
|
|
75
|
+
# fewer: "hi", "ok go") while a longer one follows, as the earlier
|
|
76
|
+
# prompts may have come before any candidate was there to ask about. A
|
|
77
|
+
# delegate child (+child+) keeps its first: the parent's request.
|
|
78
|
+
# @param texts [Array<String>] the owner's prompts, oldest first
|
|
79
|
+
# @return [Array<String>]
|
|
80
|
+
def conversation(texts, skip: 0, child: false)
|
|
81
|
+
texts = Array(texts).map(&:to_s).reject { |text| text.strip.empty? }
|
|
82
|
+
return [] if texts.empty?
|
|
83
|
+
|
|
84
|
+
first = child ? 0 : first_substantive(texts, skip)
|
|
85
|
+
picked = first == texts.size - 1 ? [texts.last] : [texts[first], texts.last]
|
|
86
|
+
picked.map { |text| bound(text) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def first_substantive(texts, skip)
|
|
90
|
+
from = skip < texts.size ? skip : 0
|
|
91
|
+
(from...texts.size).find { |index| texts[index].split.size > SHORT_WORDS } || from
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def bound(text)
|
|
95
|
+
text = text.strip
|
|
96
|
+
text.length > PROMPT_MAX ? "#{text[0, PROMPT_MAX - 1]}…" : text
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The ask's state.
|
|
100
|
+
# @return [Hash{String => Object}]
|
|
101
|
+
def state(job)
|
|
102
|
+
candidate = job.describe.to_s.strip.empty? ? job.title : "#{job.title} — #{job.describe}"
|
|
103
|
+
state = { "candidate" => candidate, "conversation" => job.conversation }
|
|
104
|
+
state["started_from"] = { "parent_attached" => job.parent_attached } if job.parent_attached
|
|
105
|
+
state
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The two questions, constant for a session (+child+: a delegate child).
|
|
109
|
+
def questions(child:)
|
|
110
|
+
{ ABOUT_ID => SystemOne::Question.new(id: ABOUT_ID, type: "noul", instructions: child ? ABOUT + CHILD : ABOUT),
|
|
111
|
+
TASK_ID => SystemOne::Question.new(id: TASK_ID, type: "noul", instructions: TASK) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# How many owner prompts at the start no ask found a task in: the
|
|
115
|
+
# latest deferred ask's count (any candidate of the session).
|
|
116
|
+
def skip(records)
|
|
117
|
+
records.select { |line| line["kind"] == "judged" && line["stated"] == false }
|
|
118
|
+
.map { |line| line["owner_prompts"].to_i }.max || 0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Whether candidate +name+ is asked now, at +owner_prompts+: never twice
|
|
122
|
+
# at one count; unjudged (no ask, or only deferred ones) → yes; one
|
|
123
|
+
# judged ask → once +reask_after_turns+ more owner prompts came; two →
|
|
124
|
+
# done.
|
|
125
|
+
def due?(records, name, owner_prompts:, reask_after_turns:)
|
|
126
|
+
mine = records.select { |line| line["kind"] == "judged" && line["name"] == name }
|
|
127
|
+
return false if mine.any? { |line| line["owner_prompts"].to_i >= owner_prompts }
|
|
128
|
+
|
|
129
|
+
judged = mine.select { |line| line["stated"] == true }
|
|
130
|
+
return true if judged.empty?
|
|
131
|
+
return false if judged.size >= 2
|
|
132
|
+
|
|
133
|
+
owner_prompts >= judged.first["owner_prompts"].to_i + reask_after_turns
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The proposed candidates due an ask (+rows+: ContextCandidates
|
|
137
|
+
# .with_status), as Jobs. +texts+ the owner's prompts, oldest first;
|
|
138
|
+
# +parent_attached+ the parent's sources' titles for a delegate child,
|
|
139
|
+
# else nil.
|
|
140
|
+
# @return [Array<Job>]
|
|
141
|
+
def jobs(rows, records, session_id:, texts:, parent_attached:, settings:, phase:)
|
|
142
|
+
owner_prompts = texts.size
|
|
143
|
+
return [] if owner_prompts.zero?
|
|
144
|
+
|
|
145
|
+
conversation = conversation(texts, skip: skip(records), child: !parent_attached.nil?)
|
|
146
|
+
rows.filter_map do |candidate, status|
|
|
147
|
+
next unless status == "proposed"
|
|
148
|
+
next unless due?(records, candidate.name, owner_prompts: owner_prompts,
|
|
149
|
+
reask_after_turns: settings.reask_after_turns)
|
|
150
|
+
|
|
151
|
+
Job.new(session_id: session_id, name: candidate.name, title: candidate.title, describe: candidate.describe,
|
|
152
|
+
conversation: conversation, parent_attached: parent_attached, owner_prompts: owner_prompts,
|
|
153
|
+
phase: phase)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# What on mode would do with +p+: attach (≥ threshold), offer (≥
|
|
158
|
+
# offer_threshold) or none.
|
|
159
|
+
def would(probability, settings)
|
|
160
|
+
return "attach" if probability >= settings.threshold
|
|
161
|
+
return "offer" if probability >= settings.offer_threshold
|
|
162
|
+
|
|
163
|
+
"none"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# One ask (on SideJudgments' thread) and its judged line. No answer
|
|
167
|
+
# (System 1 said nothing: its own warn) writes nothing, so the
|
|
168
|
+
# candidate is asked again at the next owner prompt.
|
|
169
|
+
# @return [Hash, nil] the line's fields
|
|
170
|
+
def ask(client:, job:, log:, settings:, cancel_controller: nil)
|
|
171
|
+
answers = client.ask(state: state(job), questions: questions(child: !job.parent_attached.nil?),
|
|
172
|
+
cancel_controller: cancel_controller)
|
|
173
|
+
about = answers&.dig(ABOUT_ID)&.noul
|
|
174
|
+
task = answers&.dig(TASK_ID)&.noul
|
|
175
|
+
return nil if about.nil? || task.nil?
|
|
176
|
+
|
|
177
|
+
stated = task >= TASK_MIN
|
|
178
|
+
fields = { "title" => job.title, "phase" => job.phase, "owner_prompts" => job.owner_prompts,
|
|
179
|
+
"p" => about.round(4), "task_p" => task.round(4), "stated" => stated,
|
|
180
|
+
"would" => stated ? would(about, settings) : nil, "mode" => settings.mode,
|
|
181
|
+
"threshold" => settings.threshold, "offer_threshold" => settings.offer_threshold,
|
|
182
|
+
"host" => client.host, "model" => client.model, "child" => job.parent_attached ? true : nil,
|
|
183
|
+
"conversation" => job.conversation.map { |text| ContextCandidatesLog.short(text) } }
|
|
184
|
+
ContextCandidatesLog.event("judged", session_id: job.session_id, name: job.name, log: log, **fields.transform_keys(&:to_sym))
|
|
185
|
+
fields
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def warn(error)
|
|
189
|
+
Log.warn(:context, "context_judge_failed",
|
|
190
|
+
echo: "[samagotchi:context] candidate judge: #{error.class}: #{error.message}",
|
|
191
|
+
error: error.class.name, msg: error.message.to_s[0, 200])
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context_sources"
|
|
4
|
+
require_relative "session_inbox"
|
|
5
|
+
require_relative "log"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Movable context on its two paths, a continue (SessionContinue) and a
|
|
9
|
+
# plugin fork (ctx.sessions.fork): from SessionManager.spawn_session's
|
|
10
|
+
# before_spawn, once the new session and its first note are saved and
|
|
11
|
+
# before its worker starts, so its first absorb sees the sources.
|
|
12
|
+
module ContextMove
|
|
13
|
+
# What the new session's note says it is from.
|
|
14
|
+
NOTE_SOURCE = "attached context"
|
|
15
|
+
|
|
16
|
+
# ContextSources.move, then a second note in the new session saying what
|
|
17
|
+
# moved and what stayed (named by time, it sorts after the first). Best
|
|
18
|
+
# effort: a failure is logged and the session starts without (its start
|
|
19
|
+
# must not be undone for it).
|
|
20
|
+
# @return [ContextSources::Moved]
|
|
21
|
+
def self.call(from:, to:, mode:, state_dir:, seed: nil)
|
|
22
|
+
moved = ContextSources.move(from: from, to: to, mode: mode, seed: seed, state_dir: state_dir)
|
|
23
|
+
text = moved.note_text(from)
|
|
24
|
+
SessionInbox.write_note(to, text: text, source: NOTE_SOURCE, state_dir: state_dir) if text
|
|
25
|
+
moved
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Log.warn(:context, "context_move_failed", sid: to, from: from, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
28
|
+
ContextSources::Moved.none
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
require_relative "cli/command"
|
|
6
|
+
require_relative "cli/exit"
|
|
7
|
+
require_relative "cli/flags"
|
|
8
|
+
require_relative "config"
|
|
9
|
+
require_relative "context_candidates_log"
|
|
10
|
+
require_relative "session"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# `chi context report [--days N]`: counts from context_candidates.jsonl
|
|
14
|
+
# (ContextCandidatesLog), no prompts: what was proposed and mentioned,
|
|
15
|
+
# the judge's asks, what it would attach at each threshold, and how that
|
|
16
|
+
# agrees with what the user did with the same candidates (attached,
|
|
17
|
+
# mentioned, or declined); on mode's attaches (and how many the user
|
|
18
|
+
# removed later), cards and /context attach|decline. This machine only
|
|
19
|
+
# (SessionAccess::Contract).
|
|
20
|
+
class ContextReportCommand
|
|
21
|
+
include CLI::Command
|
|
22
|
+
|
|
23
|
+
USAGE = <<~TEXT
|
|
24
|
+
Usage: chi context report [--days N]
|
|
25
|
+
Counts from the candidate judge's log (context_candidates.jsonl): candidates
|
|
26
|
+
proposed, mentioned, attached or declined by you, withdrawn; the judge's asks
|
|
27
|
+
(judged, or no task stated yet); how many candidates it would attach at each
|
|
28
|
+
threshold (each candidate's latest judged ask); and how that agrees with what
|
|
29
|
+
you did with them. On mode: the judge's attaches, how many you removed later,
|
|
30
|
+
its cards, and /context attach|decline. Default: the last 14 days.
|
|
31
|
+
TEXT
|
|
32
|
+
|
|
33
|
+
DEFAULT_DAYS = 14
|
|
34
|
+
THRESHOLDS = [0.5, 0.6, 0.7, 0.8, 0.9].freeze
|
|
35
|
+
FLAGS = CLI::Flags.new(args: false) { |flags| flags.value "--days" }.freeze
|
|
36
|
+
|
|
37
|
+
# @param argv [Array<String>] the arguments after "context report"
|
|
38
|
+
# @param state_dir [String, nil] the sessions folder (a spec seam)
|
|
39
|
+
# @param now [Time] the report's clock (a spec seam)
|
|
40
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil, now: Time.now)
|
|
41
|
+
@argv = argv.dup
|
|
42
|
+
@stdout = stdout
|
|
43
|
+
@stderr = stderr
|
|
44
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
45
|
+
@now = now
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run
|
|
49
|
+
parsed = parse_flags(FLAGS, @argv)
|
|
50
|
+
return parsed if parsed.is_a?(Integer)
|
|
51
|
+
|
|
52
|
+
days = parsed.options[:days]
|
|
53
|
+
return usage_error("--days takes a positive whole number") if days && !days.to_s.match?(/\A[1-9]\d*\z/)
|
|
54
|
+
|
|
55
|
+
report(days ? Integer(days, 10) : DEFAULT_DAYS)
|
|
56
|
+
CLI::Exit::OK
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The counts, as a Hash (the report prints these).
|
|
60
|
+
def self.counts(records, threshold:, offer_threshold:)
|
|
61
|
+
by_candidate = records.group_by { |line| [line["session_id"], line["name"]] }
|
|
62
|
+
kinds = ->(kind) { records.count { |line| line["kind"] == kind } }
|
|
63
|
+
judged = records.select { |line| line["kind"] == "judged" }
|
|
64
|
+
latest = by_candidate.transform_values do |lines|
|
|
65
|
+
lines.reverse.find { |line| line["kind"] == "judged" && line["stated"] == true }&.fetch("p", nil)
|
|
66
|
+
end.compact
|
|
67
|
+
choices = by_candidate.transform_values { |lines| user_choice(lines) }.compact
|
|
68
|
+
{
|
|
69
|
+
sessions: records.map { |line| line["session_id"] }.uniq.size,
|
|
70
|
+
proposed: kinds.call("proposed"), mentioned: kinds.call("mentioned"), attached: kinds.call("attached"),
|
|
71
|
+
declined: kinds.call("declined"), detached: kinds.call("detached"), withdrawn: kinds.call("withdrawn"),
|
|
72
|
+
asks: judged.size, stated: judged.count { |line| line["stated"] == true },
|
|
73
|
+
unstated: judged.count { |line| line["stated"] == false }, candidates_judged: latest.size,
|
|
74
|
+
would: THRESHOLDS.to_h { |t| [t, latest.values.count { |p| p.to_f >= t }] },
|
|
75
|
+
threshold: threshold, offer_threshold: offer_threshold,
|
|
76
|
+
agreement: agreement(latest, choices, threshold)
|
|
77
|
+
}.merge(on_mode(records, by_candidate))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# On mode's own lines: the judge's attaches, those the user removed
|
|
81
|
+
# later (its false positives), the cards, and /context attach|decline.
|
|
82
|
+
def self.on_mode(records, by_candidate)
|
|
83
|
+
by_command = ->(kind) { records.count { |line| line["kind"] == kind && line["by"] == "command" } }
|
|
84
|
+
removed = by_candidate.values.count do |lines|
|
|
85
|
+
index = lines.index { |line| line["kind"] == "judge_attached" }
|
|
86
|
+
index && lines.drop(index + 1).any? { |line| %w[declined detached].include?(line["kind"]) && line["by"] != "command" }
|
|
87
|
+
end
|
|
88
|
+
{ judge_attached: records.count { |line| line["kind"] == "judge_attached" }, judge_removed: removed,
|
|
89
|
+
carded: records.count { |line| line["kind"] == "carded" }, command_attached: by_command.call("attached"),
|
|
90
|
+
command_declined: by_command.call("declined") }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What the user last did with a candidate: "attached" (attached by
|
|
94
|
+
# them, or mentioned) or "declined"; nil when neither.
|
|
95
|
+
def self.user_choice(lines)
|
|
96
|
+
last = lines.reverse.find { |line| %w[mentioned attached declined detached].include?(line["kind"]) }
|
|
97
|
+
return nil if last.nil? || last["kind"] == "detached"
|
|
98
|
+
|
|
99
|
+
last["kind"] == "declined" ? "declined" : "attached"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The judged candidates the user also chose for: agree (would attach
|
|
103
|
+
# what they attached, or not what they declined), attached what they
|
|
104
|
+
# declined, missed what they attached.
|
|
105
|
+
def self.agreement(latest, choices, threshold)
|
|
106
|
+
both = latest.keys & choices.keys
|
|
107
|
+
result = { candidates: both.size, agree: 0, wrong_attach: 0, missed: 0 }
|
|
108
|
+
both.each do |key|
|
|
109
|
+
would = latest[key].to_f >= threshold
|
|
110
|
+
if would == (choices[key] == "attached") then result[:agree] += 1
|
|
111
|
+
elsif would then result[:wrong_attach] += 1
|
|
112
|
+
else result[:missed] += 1
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
result
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def command_name = "chi context report"
|
|
121
|
+
|
|
122
|
+
def report(days)
|
|
123
|
+
cutoff = @now - (days * 86_400)
|
|
124
|
+
records = ContextCandidatesLog.log_for(@state_dir).records.select { |line| after?(line["at"], cutoff) }
|
|
125
|
+
counts = self.class.counts(records, threshold: Config.get("context.candidates.threshold").to_f,
|
|
126
|
+
offer_threshold: Config.get("context.candidates.offer_threshold").to_f)
|
|
127
|
+
print_counts(counts, days)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def after?(iso, cutoff)
|
|
131
|
+
Time.iso8601(iso.to_s) >= cutoff
|
|
132
|
+
rescue ArgumentError
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def print_counts(counts, days)
|
|
137
|
+
@stdout.puts("chi context report: the last #{days} day#{"s" unless days == 1}, #{counts[:sessions]} session" \
|
|
138
|
+
"#{"s" unless counts[:sessions] == 1} (mode #{Config.get("context.candidates.mode")})")
|
|
139
|
+
@stdout.puts(" candidates: proposed #{counts[:proposed]} · mentioned #{counts[:mentioned]} · " \
|
|
140
|
+
"attached by you #{counts[:attached]} · declined #{counts[:declined]} · " \
|
|
141
|
+
"detached #{counts[:detached]} · withdrawn #{counts[:withdrawn]}")
|
|
142
|
+
@stdout.puts(" on mode: judge attached #{counts[:judge_attached]} · you removed #{counts[:judge_removed]} of them " \
|
|
143
|
+
"later · carded #{counts[:carded]} · /context attach #{counts[:command_attached]} · " \
|
|
144
|
+
"/context decline #{counts[:command_declined]} (the card's buttons)")
|
|
145
|
+
@stdout.puts(" judge: #{counts[:asks]} asks · judged #{counts[:stated]} · no task yet #{counts[:unstated]} · " \
|
|
146
|
+
"#{counts[:candidates_judged]} candidates judged")
|
|
147
|
+
return if counts[:candidates_judged].zero?
|
|
148
|
+
|
|
149
|
+
@stdout.puts(" would attach (each candidate's latest judged ask):")
|
|
150
|
+
counts[:would].each do |threshold, count|
|
|
151
|
+
mark = { counts[:threshold] => " threshold", counts[:offer_threshold] => " offer_threshold" }[threshold]
|
|
152
|
+
@stdout.puts(format(" ≥ %.1f %d%s", threshold, count, mark))
|
|
153
|
+
end
|
|
154
|
+
agreement = counts[:agreement]
|
|
155
|
+
@stdout.puts(" against your own choice (#{agreement[:candidates]} judged candidates you attached, " \
|
|
156
|
+
"mentioned or declined), at #{counts[:threshold]}: agree #{agreement[:agree]} · " \
|
|
157
|
+
"would attach what you declined #{agreement[:wrong_attach]} · " \
|
|
158
|
+
"would miss what you attached #{agreement[:missed]}")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|