samagotchi 0.145.0 → 0.146.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.
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "memory_paths"
6
+ require_relative "memory_bundle/index_size"
7
+ require_relative "model_notes"
8
+ require_relative "session"
9
+ require_relative "llm_context_stale"
10
+ require_relative "working_set"
11
+ require_relative "file_ref"
12
+ require_relative "tool_response"
13
+ require_relative "tool_output_cut"
14
+ require_relative "model_overlay"
15
+ require_relative "tools/memory"
16
+
17
+ module Samagotchi
18
+ # `chi memory usage` (docs/memory.md): what the memories cost and what the
19
+ # saved sessions really read. Reading only: no writes, no model call, no
20
+ # grades and no advice. It is given the entries (MemoryCommand.entries) and
21
+ # the sessions to count, and returns Data values the command prints.
22
+ #
23
+ # A read is a tool run that pairs with its call (LLMContextStale.paired_runs,
24
+ # so both loops' saved shapes count: the chat loop's tool_calls with their
25
+ # tool_call_id, the native loop's call markup with one joined result), of
26
+ # `memory_read` or of `read` on a memory's file. Its cost is the size of the
27
+ # result as it was saved - a cut result counts as it happened, an overlay
28
+ # too - in tokens the way the prompt's own estimate does (characters /
29
+ # context.chars_per_token). A `memory_read` of a comma list splits that one
30
+ # result by share, as WorkingSet.item does.
31
+ #
32
+ # A project memory is credited only with the reads of sessions of this
33
+ # folder's project (MemoryPaths.project_root: worktrees of one repository
34
+ # share it); a system memory with every session's. Sessions are the saved
35
+ # files, so retention limits what can be seen: that is why the header says
36
+ # how many sessions and how many days.
37
+ class MemoryUsage
38
+ # The memory scopes, project first, as `chi memory ls` reports them.
39
+ SCOPES = Tools::VALID_SCOPES
40
+ MEMORY_READ = Tools::MemoryRead::NAME
41
+ FILE_READ = Tools::Read::NAME
42
+
43
+ # One memory read at least once in the sessions counted: its tokens (the
44
+ # results' size) and reads, +cut_reads+ of them returned a cut result,
45
+ # +bytes+ its file now (nil: no file) and +size_tokens+ that size (nil
46
+ # when there is no file), +source+ the bundle that owns it.
47
+ Use = Data.define(:name, :scope, :tokens, :reads, :cut_reads, :bytes, :size_tokens, :source) do
48
+ def to_h = super.transform_keys(&:to_s)
49
+ end
50
+
51
+ # One scope's index as every prompt pays it: its tokens, the warning
52
+ # limit (0: off), its memory lines, and those naming a memory no counted
53
+ # session read.
54
+ Index = Data.define(:scope, :tokens, :limit, :lines, :unread_lines) do
55
+ def unread_percent = lines.zero? ? 0 : (100.0 * unread_lines / lines).round
56
+
57
+ def to_h = { "scope" => scope, "tokens" => tokens, "limit" => limit,
58
+ "lines" => lines, "unread_lines" => unread_lines, "unread_percent" => unread_percent }
59
+ end
60
+
61
+ # A memory of the user's own no counted session read: +bytes+ its file,
62
+ # +tokens+ that size. A candidate, not a verdict.
63
+ Unread = Data.define(:name, :scope, :bytes, :tokens) do
64
+ def to_h = super.transform_keys(&:to_s)
65
+ end
66
+
67
+ # Everything the command prints. +sessions+ are the ones counted (no test
68
+ # runs, none without a user message, archived ones included), +skipped+
69
+ # the saved files not counted for that reason, +unreadable+ the files that
70
+ # could not be read; +top+ and +not_read+ are limited to +top+ rows each.
71
+ Report = Data.define(:sessions, :skipped, :unreadable, :days, :indexes, :read_count, :read_tokens, :cut_reads,
72
+ :read_names, :unknown_names, :top, :not_read, :not_read_count, :not_read_total) do
73
+ def to_h
74
+ {
75
+ "sessions" => sessions, "skipped" => skipped, "unreadable" => unreadable, "days" => days,
76
+ "indexes" => indexes.map(&:to_h),
77
+ "reads" => { "count" => read_count, "tokens" => read_tokens, "cut_reads" => cut_reads,
78
+ "names" => read_names },
79
+ "top" => top.map(&:to_h),
80
+ "not_read" => { "count" => not_read_count, "total" => not_read_total, "largest" => not_read.map(&:to_h) },
81
+ "unknown_names" => unknown_names
82
+ }
83
+ end
84
+ end
85
+
86
+ # The saved sessions to count, each file parsed once (Session.list then
87
+ # Session.load would parse twice and drop corrupt files silently).
88
+ # @return [Array(Array<Session>, Integer)] the sessions and the files that could not be read
89
+ def self.load_sessions(state_dir: Session.default_state_dir)
90
+ sessions = []
91
+ unreadable = 0
92
+ Dir.glob(File.join(state_dir, "*#{Session::FILE_EXT}")).sort.each do |path|
93
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
94
+ # A project_root that is not a path (a hand-edited file) makes every
95
+ # later step raise, so the file counts as unreadable rather than
96
+ # taking the whole report down.
97
+ raise ArgumentError, "project_root is not a path" unless data["project_root"].nil? || data["project_root"].is_a?(String)
98
+
99
+ sessions << Session.from_h(data)
100
+ rescue StandardError
101
+ unreadable += 1
102
+ end
103
+ [sessions, unreadable]
104
+ end
105
+
106
+ # @param sessions [Array<Session>] the saved sessions (load_sessions, or a spec's)
107
+ # @param entries [Array<MemoryCommand::Entry>] the memories to report on
108
+ # @param scopes [Array<String>] the scopes to measure their indexes for
109
+ # @param unreadable [Integer] session files that could not be read
110
+ # @param baseline [Array<String>] memories no read is needed for: config.yml's
111
+ # `memories:` and the system memories every prompt carries
112
+ # (SystemPrompt::DEFAULT_SYSTEM_MEMORIES). They are in the prompt from the
113
+ # start, so they are never saved as used or preloaded in the session file.
114
+ # @param root [String] the folder whose project's memories are the project scope
115
+ # @param env [Hash] the XDG env the memories dirs resolve from
116
+ def initialize(sessions:, entries:, scopes: SCOPES, unreadable: 0, baseline: [], root: Dir.pwd, env: ENV,
117
+ chars_per_token: MemoryBundle::IndexSize.chars_per_token,
118
+ warn_limit: MemoryBundle::IndexSize.warn_limit)
119
+ @sessions = Array(sessions)
120
+ @entries = Array(entries)
121
+ @scopes = Array(scopes).map(&:to_s)
122
+ @unreadable = unreadable.to_i
123
+ @baseline = Array(baseline).map { |raw| name_of(raw) }
124
+ # The project the report is for, as MemoryPaths keys it (a worktree
125
+ # resolves to its repository's root; both spellings of a path, /var and
126
+ # /private/var, mean the same project).
127
+ @root = expand(MemoryPaths.project_root(File.expand_path(root.to_s)))
128
+ @env = env
129
+ @chars_per_token = chars_per_token.positive? ? chars_per_token.to_f : 4.0
130
+ @warn_limit = warn_limit.to_i
131
+ end
132
+
133
+ # @param top [Integer] the rows the report keeps per section
134
+ # @return [Report]
135
+ def report(top: 10)
136
+ known = {}
137
+ gone = {}
138
+ # [scope, name]: a session's used names carry no scope, and another
139
+ # project's can't vouch for this project's memory of the same name.
140
+ used = Set.new(@baseline.flat_map { |name| SCOPES.map { |scope| [scope, name] } })
141
+ times = []
142
+ sessions = 0
143
+ skipped = 0
144
+
145
+ @sessions.each do |session|
146
+ if session.test_run || !session.messages.any? { |entry| entry[:role].to_s == "user" }
147
+ skipped += 1
148
+ next
149
+ end
150
+
151
+ sessions += 1
152
+ time = time_of(session.updated_at)
153
+ times << time if time
154
+ project = project_session?(session)
155
+ project_dir = project_dir_of(session)
156
+
157
+ (Array(session.used_memory_names) + Array(session.preloaded_memory_names)).each do |raw|
158
+ used << ["system", name_of(raw)]
159
+ used << ["project", name_of(raw)] if project
160
+ end
161
+
162
+ reads(session, project, project_dir).each do |scope, name, tokens, cut|
163
+ next unless @scopes.include?(scope) # --scope system leaves a project's reads out
164
+
165
+ used << [scope, name]
166
+ # A name with no memory behind it (deleted, or a scope nobody asked
167
+ # for) counts in the totals, but never as a row.
168
+ bucket = entry_for(scope, name) ? known : gone
169
+ row = bucket[[scope, name]] ||= { tokens: 0, reads: 0, cut_reads: 0 }
170
+ row[:tokens] += tokens
171
+ row[:reads] += 1
172
+ row[:cut_reads] += 1 if cut
173
+ end
174
+ end
175
+
176
+ rows = known.map do |(scope, name), acc|
177
+ entry = entry_for(scope, name)
178
+ Use.new(name: name, scope: scope, tokens: acc[:tokens], reads: acc[:reads], cut_reads: acc[:cut_reads],
179
+ bytes: entry.bytes, size_tokens: entry.bytes ? token_count(entry.bytes) : nil, source: entry.source)
180
+ end.sort_by { |row| [-row.tokens, row.name] }
181
+
182
+ unread = @entries.reject { |entry| entry.source || used.include?([entry.scope.to_s, entry.name]) }
183
+ .sort_by { |entry| [-entry.bytes.to_i, entry.name] }
184
+ not_read = unread.first(top).map do |entry|
185
+ Unread.new(name: entry.name, scope: entry.scope, bytes: entry.bytes, tokens: entry.bytes ? token_count(entry.bytes) : nil)
186
+ end
187
+
188
+ Report.new(
189
+ sessions: sessions,
190
+ skipped: skipped,
191
+ unreadable: @unreadable,
192
+ days: times.empty? ? 0 : ((times.max - times.min) / 86_400.0).round,
193
+ indexes: @scopes.map { |scope| index_row(scope, used) },
194
+ read_count: known.sum { |_, acc| acc[:reads] } + gone.sum { |_, acc| acc[:reads] },
195
+ read_tokens: known.sum { |_, acc| acc[:tokens] } + gone.sum { |_, acc| acc[:tokens] },
196
+ cut_reads: known.sum { |_, acc| acc[:cut_reads] } + gone.sum { |_, acc| acc[:cut_reads] },
197
+ read_names: known.size,
198
+ unknown_names: gone.size,
199
+ top: rows.first(top),
200
+ not_read: not_read,
201
+ not_read_count: unread.size,
202
+ not_read_total: @entries.reject(&:source).size
203
+ )
204
+ end
205
+
206
+ private
207
+
208
+ def entries_by_scope
209
+ @entries_by_scope ||= @entries.to_h { |entry| [entry.scope.to_s, {}] }.tap do |by_scope|
210
+ @entries.each { |entry| by_scope[entry.scope.to_s][entry.name] ||= entry }
211
+ end
212
+ end
213
+
214
+ # The entry a counted read's [scope, name] is, nil for a name with no
215
+ # memory now (a deleted one, a scope no entries were asked for).
216
+ def entry_for(scope, name)
217
+ entries_by_scope.fetch(scope, {})[name]
218
+ end
219
+
220
+ # One scope's index: its tokens and lines as the prompt pays them, and how
221
+ # many of its memory lines name a memory no counted session read. Only the
222
+ # lines of memories count (MemoryCommand::LINE, and the entries `chi memory
223
+ # ls` finds behind them): a heading or a bold line of prose is neither the
224
+ # denominator nor an unread line.
225
+ def index_row(scope, used)
226
+ text = index_text(scope)
227
+ size = MemoryBundle::IndexSize.of_text(scope, text, chars_per_token: @chars_per_token)
228
+ names = text.each_line.filter_map { |line| (match = MemoryCommand::LINE.match(line)) && match[1] }.uniq
229
+ entries = names.select { |name| entry_for(scope, name) }
230
+ unread = entries.count { |name| !used.include?([scope, name]) }
231
+ Index.new(scope: scope, tokens: size.tokens, limit: @warn_limit, lines: entries.size, unread_lines: unread)
232
+ end
233
+
234
+ def index_text(scope)
235
+ raw = Tools::MemoryRead.scoped_index(scope, env: @env)
236
+ ModelNotes.filter_index(raw, scope, env: @env)
237
+ end
238
+
239
+ # The reads of one session: [scope, name, tokens, cut?].
240
+ def reads(session, project, project_dir)
241
+ LLMContextStale.paired_runs(session.messages).flat_map do |run|
242
+ call = run.call
243
+ name = call[:name].to_s
244
+ next [] if failed?(run.text)
245
+
246
+ if name == MEMORY_READ
247
+ # index.md is not a memory: the model reads it by name now and then,
248
+ # and its cost is the Indexes section above - but it is one of the
249
+ # names the one result is split between, as WorkingSet does.
250
+ found = WorkingSet.found_names(run)
251
+ names = found.reject { |memory| memory.casecmp?(Tools::MEMORY_INDEX) }
252
+ next [] if names.empty?
253
+
254
+ tokens = token_count(run.text.length, found.size)
255
+ cut = cut_result?(run.text)
256
+ names.filter_map do |memory|
257
+ scope = scope_of(memory, call[:scope], project_dir)
258
+ scope == "project" && !project ? nil : [scope, memory, tokens, cut]
259
+ end
260
+ elsif name == FILE_READ
261
+ found = memory_file(FileRef.for(name, call, cwd: session.working_directory)&.path, project_dir)
262
+ # A project memory is credited only with this project's sessions.
263
+ next [] if found.nil? || (found.first == "project" && !project)
264
+
265
+ [[found.first, found.last, token_count(run.text.length), cut_result?(run.text)]]
266
+ else
267
+ []
268
+ end
269
+ end
270
+ end
271
+
272
+ # WorkingSet's rule: an errored or interrupted run touched nothing.
273
+ def failed?(text)
274
+ ToolResponse.interrupted_text?(text) || text.sub(ToolResponse::LEAD, "").lstrip.start_with?("Error")
275
+ end
276
+
277
+ # A result the runner cut: one of ToolOutputCut's marker lines is its own
278
+ # line. Reads saved before whole reads hold cut results, so the cut
279
+ # caveat below is one line, not a section; a memory that only mentions
280
+ # "[cut: ..." in its body is not one.
281
+ def cut_result?(text)
282
+ text.to_s.each_line.any? { |line| ToolOutputCut.cut_marker_line?(line) }
283
+ end
284
+
285
+ # Which scope a name a session read belongs to: the scope the call gave,
286
+ # else the session's project when its file is there, else system.
287
+ def scope_of(name, scope, project_dir)
288
+ given = scope.to_s
289
+ return given if SCOPES.include?(given)
290
+
291
+ project_dir && File.file?(File.join(project_dir, "#{name}.md")) ? "project" : "system"
292
+ end
293
+
294
+ # A read of a memory's file: [scope, name], nil for any other file. The
295
+ # session's own project folder (MemoryPaths.key_for_root), since
296
+ # IndexSync.memory_scope knows only the folder chi is running in.
297
+ def memory_file(path, project_dir)
298
+ return nil if path.nil? || !path.end_with?(".md")
299
+
300
+ dir = expand(File.dirname(path))
301
+ stem = File.basename(path, ".md")
302
+ return nil if stem.empty? || stem.casecmp?(Tools::MEMORY_INDEX) || stem.start_with?(".")
303
+
304
+ if dir == expand(MemoryPaths.system_dir(env: @env))
305
+ ["system", base_memory("system", stem)]
306
+ elsif project_dir && dir == expand(project_dir)
307
+ ["project", base_memory("project", stem)]
308
+ end
309
+ end
310
+
311
+ # The memory a file's stem names. A model overlay's file (<base>.<key>.md,
312
+ # ModelOverlay and Tools::MemoryWrite.overlay_files) is a read of the
313
+ # memory it overlays - unless the stem is a memory of its own, which
314
+ # overlay_files also checks: a name with a dot (handoff_x.v2) has an
315
+ # index line, so it is listed by `chi memory ls`.
316
+ def base_memory(scope, stem)
317
+ base = ModelOverlay.base_file_for("#{stem}.md")
318
+ return stem if base.nil?
319
+
320
+ named = File.basename(base, ".md")
321
+ # A stem that is a memory of its own keeps its own reads; an orphan
322
+ # overlay (its base is gone) stays the name its file has.
323
+ return stem if entry_for(scope, stem) || entry_for(scope, named).nil?
324
+
325
+ named
326
+ end
327
+
328
+ def expand(dir)
329
+ File.realpath(dir)
330
+ rescue SystemCallError
331
+ File.expand_path(dir)
332
+ end
333
+
334
+ # The project a session belongs to: the root its file stores, or (a file
335
+ # from before the field, or a folder in no repository) the root of the
336
+ # folder it works in.
337
+ def session_project_root(session)
338
+ root = session.project_root ||
339
+ (session.working_directory.to_s.empty? ? nil : MemoryPaths.project_root(File.expand_path(session.working_directory.to_s)))
340
+ root && expand(root)
341
+ end
342
+
343
+ def project_dir_of(session)
344
+ root = session_project_root(session)
345
+ root ? File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for_root(root)) : nil
346
+ end
347
+
348
+ # A session of this folder's project (worktrees of one repository share it).
349
+ def project_session?(session)
350
+ session_project_root(session) == @root
351
+ end
352
+
353
+ # "system/delegated" is the memory named delegated.
354
+ def name_of(raw)
355
+ File.basename(raw.to_s, ".md").strip
356
+ end
357
+
358
+ def token_count(chars, share = 1)
359
+ (chars.to_i / @chars_per_token / share).ceil
360
+ end
361
+
362
+ def time_of(value)
363
+ Time.parse(value.to_s)
364
+ rescue StandardError
365
+ nil
366
+ end
367
+ end
368
+ end
@@ -94,12 +94,12 @@ module Samagotchi
94
94
  end
95
95
 
96
96
  # The child's options, by index: "session" is the child's session, so
97
- # it says whose; "once", "repo", "rule" and Deny keep their labels (the
97
+ # it says whose (one more hop names the longer chain), and keeps what it covers; "once", "repo", "rule" and Deny keep their labels (the
98
98
  # approvals store is shared, a session scope keys on the child's id).
99
99
  def relabel(options, scopes, who)
100
100
  scopes = Array(scopes)
101
101
  options.each_with_index.map do |label, index|
102
- scopes[index].to_s == "session" ? "Allow this call for delegate #{who}'s session" : label.to_s
102
+ scopes[index].to_s == "session" ? label.to_s.sub(/for (?:the|delegate .*'s) session\z/, "for delegate #{who}'s session") : label.to_s
103
103
  end
104
104
  end
105
105
 
@@ -45,9 +45,9 @@ module Samagotchi
45
45
 
46
46
  # A call the guardrails denied, as its saved text reads after the
47
47
  # "[tool]" lead (ToolRunner#denied, Guardrails::Verdict#deny_text): a
48
- # rule's or hook's deny, a legacy hook veto, or the user declining (or
49
- # cancelling) the approval.
50
- GUARDRAIL_DENIED = /\AError: (?:(?:denied|blocked) by guardrail\b|The (?:user declined this call|approval was cancelled)\b.*? It needed approval \()/m
48
+ # rule's or hook's deny, a legacy hook veto, or the user or a parent agent
49
+ # declining (or cancelling) the approval.
50
+ GUARDRAIL_DENIED = /\AError: (?:(?:denied|blocked) by guardrail\b|The (?:(?:user|parent agent) declined this call|approval was cancelled)\b.*? It needed approval \()/m
51
51
 
52
52
  # Whether a saved result text (#saved_result_text) is a guardrail's
53
53
  # deny: "blocked" as live (its activity's status), not "error", for a
@@ -35,9 +35,20 @@ module Samagotchi
35
35
  REF_WHAT = { "task_wait" => "the task's whole output", "delegate" => "the child's whole reply",
36
36
  "delegate_result" => "the child's whole reply" }.freeze
37
37
 
38
+ # How every cut marker line opens: "[cut: N of M chars" or "[cut: N
39
+ # chars" - head_line, memory_line and the gap lines all end the result
40
+ # with one of these.
41
+ CUT_OPEN = /\[cut: \d+(?: of \d+)? chars/
42
+
38
43
  # A head cut's last line (head_line), as LLMContextStale recognizes a
39
44
  # partial read by it. Kept in sync with head_line by its spec.
40
- HEAD_CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
45
+ HEAD_CUT = /#{CUT_OPEN}; read it in parts\]\z/
46
+
47
+ # Is +line+ one of those markers on its own line? A body that only
48
+ # mentions "[cut: ..." is not a cut result.
49
+ def self.cut_marker_line?(line)
50
+ line.to_s.strip.match?(/\A#{CUT_OPEN}[^\n]*\]\z/)
51
+ end
41
52
 
42
53
  # What a web row keeps of a tool's output, on a reload
43
54
  # (MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
@@ -18,6 +18,9 @@ module Samagotchi
18
18
  class ToolkitSuggest
19
19
  FILE = "toolkit_suggestions.jsonl"
20
20
  QUESTION_ID = "toolkit"
21
+ # The defer sentinel: the choice "There is no task yet", asked as a
22
+ # criterion, never offered as a kit, and hidden from the web's picker.
23
+ UNDECIDED = "undecided"
21
24
  # Its saved asks' name (SystemOne::Runs).
22
25
  JUDGMENT = "toolkit.suggest"
23
26
  PROMPT_MAX = 1500
@@ -42,7 +45,7 @@ module Samagotchi
42
45
  # not the whole ask, and the ≥ 2 clause counts what is left.
43
46
  def self.candidates(kits, default_name:)
44
47
  kits.values.filter_map do |kit|
45
- next if [default_name.to_s, Toolkit::REVIEW, "undecided"].include?(kit.name)
48
+ next if [default_name.to_s, Toolkit::REVIEW, UNDECIDED].include?(kit.name)
46
49
  next if kit.description.to_s.empty?
47
50
  next unless resolves?(kit.name, kits)
48
51
 
@@ -62,7 +65,7 @@ module Samagotchi
62
65
  candidates = candidates(kits, default_name: default.name)
63
66
  criteria = candidates.to_h { |kit| ["kit:#{kit.name}", "#{kit.name}: #{kit.description}"] }
64
67
  criteria["default"] = "none of the other toolkits fits: the user's everyday default"
65
- criteria["undecided"] = "There is no task yet; defer this choice"
68
+ criteria[UNDECIDED] = "There is no task yet; defer this choice"
66
69
  question = SystemOne::Question.new(id: QUESTION_ID, type: :choice, instructions: INSTRUCTIONS, criteria: criteria)
67
70
  [question, { project: project.to_s, prompt: prompt.to_s[0, PROMPT_MAX] }]
68
71
  end
@@ -81,7 +84,7 @@ module Samagotchi
81
84
 
82
85
  messages = Array(session.messages)
83
86
  return true unless model_reply?(messages)
84
- return false unless field(value(last_suggestion), :raw) == "undecided"
87
+ return false unless field(value(last_suggestion), :raw) == UNDECIDED
85
88
 
86
89
  current_turn_number = turn_number(messages)
87
90
  current_turn_number <= defer_turns.to_i && conversation_chars(messages) / CHARS_PER_TOKEN < defer_tokens.to_i
@@ -139,7 +142,7 @@ module Samagotchi
139
142
  decided = raw == "default" || (candidate && confidence && confidence >= threshold.to_f)
140
143
  would = if answer.nil?
141
144
  "no_judgment"
142
- elsif raw == "undecided"
145
+ elsif raw == UNDECIDED
143
146
  "defer"
144
147
  elsif raw == "default"
145
148
  "default"
@@ -5,6 +5,7 @@ require_relative "../session"
5
5
  require_relative "../config"
6
6
  require_relative "../model_profile"
7
7
  require_relative "../memory_paths"
8
+ require_relative "../guardrails/context"
8
9
  require_relative "peers"
9
10
  require_relative "delegate_wait"
10
11
  require_relative "../child_reports"
@@ -27,6 +28,9 @@ module Samagotchi
27
28
  NAME = "delegate"
28
29
  MAX_CHILDREN_KEY = "session.max_children"
29
30
  MAX_CHILDREN_DEFAULT = 4
31
+ # A path in a task: absolute, ~/ or ../, not glued to a word (a URL's
32
+ # /api is not one) and stopping at quotes, backticks or punctuation.
33
+ PATH_TOKEN = %r{(?<![\w./~-])(?:~/|\.\./|/(?!/))[^\s`'"<>()\[\]{},;&|]*}
30
34
  # A child spawned this many seconds ago counts as running even before
31
35
  # its worker holds the owner lock: two delegate calls in one model
32
36
  # message come milliseconds apart, and the first child's worker is
@@ -60,19 +64,19 @@ module Samagotchi
60
64
 
61
65
  cwd = cwd.to_s.strip
62
66
  toolkit = toolkit.to_s.strip
63
- child_id, warning = if session.to_s.strip.empty?
64
- start_child(task, parent: parent, model: model, cwd: cwd, toolkit: toolkit, state_dir: sd,
65
- peers: peers)
66
- elsif !cwd.empty?
67
- "Error: cwd starts a new child; a follow-up with session keeps the child's folder"
68
- elsif !toolkit.empty?
69
- "Error: toolkit starts a new child; a follow-up keeps the child's toolkit"
70
- else
71
- follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
72
- end
67
+ child_id, warnings = if session.to_s.strip.empty?
68
+ start_child(task, parent: parent, model: model, cwd: cwd, toolkit: toolkit, state_dir: sd,
69
+ peers: peers)
70
+ elsif !cwd.empty?
71
+ "Error: cwd starts a new child; a follow-up with session keeps the child's folder"
72
+ elsif !toolkit.empty?
73
+ "Error: toolkit starts a new child; a follow-up keeps the child's toolkit"
74
+ else
75
+ follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
76
+ end
73
77
  return child_id if child_id.start_with?("Error:")
74
78
 
75
- note = warning ? "Warning: #{warning}\n" : ""
79
+ note = Array(warnings).map { |warning| "Warning: #{warning}\n" }.join
76
80
  if wait
77
81
  result = DelegateWait.call(child_id, peers: peers, timeout: timeout)
78
82
  return RefText.wrap(note + result, result.respond_to?(:ref_path) ? result.ref_path : nil)
@@ -114,7 +118,8 @@ module Samagotchi
114
118
  private_class_method :full_hint
115
119
 
116
120
  # @param toolkit [String] a kit's name, "" for config's toolkit.child
117
- # @return [String] the new child's id, or an Error: line
121
+ # @return [String, Array<String>] the new child's id, or an Error: line,
122
+ # and its warnings (each without the "Warning: " prefix)
118
123
  def self.start_child(task, parent:, model:, cwd:, toolkit:, state_dir:, peers:)
119
124
  if parent.parent_id
120
125
  return "Error: this session is a delegate of #{parent.parent_id}; delegated sessions don't delegate further"
@@ -142,10 +147,80 @@ module Samagotchi
142
147
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
143
148
  parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
144
149
  DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
145
- [child.id, child.model_warning]
150
+ # The line for a task that names a worktree while the child runs in
151
+ # the parent's folder (no cwd: given): the child still starts.
152
+ [child.id, [child.model_warning, other_checkout(task, folder, parent: parent, child_id: child.id,
153
+ no_cwd: cwd.empty?)].compact]
146
154
  end
147
155
  private_class_method :start_child
148
156
 
157
+ # A new child with no cwd: its task may name another checkout of this
158
+ # repository (a worktree the parent meant to pass as cwd:), which makes
159
+ # every write there an ask for the user (child-boundary). One warning
160
+ # line, however many are named; nil when the task names none, or names
161
+ # only the checkout holding the child's folder (or a subfolder of it).
162
+ # Never raises: a started child must not come back as an Error:.
163
+ # @return [String, nil]
164
+ def self.other_checkout(task, folder, parent:, child_id:, no_cwd:)
165
+ return nil unless no_cwd
166
+
167
+ checkout = foreign_checkout(task, folder)
168
+ return nil unless checkout
169
+
170
+ Log.info(:worker, "delegate_other_checkout", parent: parent.id[0, 8])
171
+ "this child runs in #{folder}, but its task names #{checkout}. If it should work there, stop it " \
172
+ "(chi sessions stop #{child_id}) and call delegate again without session, with cwd: #{checkout}."
173
+ rescue StandardError
174
+ nil
175
+ end
176
+ private_class_method :other_checkout
177
+
178
+ # The checkout of +folder+'s repository that the task names and that is
179
+ # not the one holding +folder+: the first path token in the task that
180
+ # belongs to one (the deepest containing it), else nil. Reads files
181
+ # only, no git process, so a spec stubbing Process.spawn is unaffected.
182
+ # @return [String, nil] an absolute checkout folder
183
+ def self.foreign_checkout(task, folder)
184
+ common = MemoryPaths.common_git_dir(folder)
185
+ return nil unless common
186
+
187
+ checkouts = Guardrails::GitInfo.checkouts(common).map { |checkout| File.realpath(checkout) }
188
+ own = deepest_checkout(File.realpath(folder), checkouts)
189
+ task.to_s.scan(PATH_TOKEN) do |token|
190
+ path = existing_ancestor(File.expand_path(token.sub(/[.:!?]+\z/, ""), folder))
191
+ next if path.nil?
192
+
193
+ found = deepest_checkout(path, checkouts)
194
+ return found if found && found != own
195
+ end
196
+ nil
197
+ end
198
+ private_class_method :foreign_checkout
199
+
200
+ # The checkout containing +path+, matched by whole components (so
201
+ # /x/app is not found in /x/app-fix): the deepest, so a nested worktree's
202
+ # path belongs to that worktree, not to main; nil when none does.
203
+ def self.deepest_checkout(path, checkouts)
204
+ checkouts.select { |checkout| within?(path, checkout) }.max_by { |checkout| checkout.count("/") }
205
+ end
206
+ private_class_method :deepest_checkout
207
+
208
+ # +path+'s longest ancestor that exists, realpathed (a worktree not made
209
+ # yet still points at its parent folder); nil when nothing above it does.
210
+ def self.existing_ancestor(path)
211
+ dir = path
212
+ until File.exist?(dir)
213
+ parent = File.dirname(dir)
214
+ return nil if parent == dir
215
+
216
+ dir = parent
217
+ end
218
+ File.realpath(dir)
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ private_class_method :existing_ancestor
223
+
149
224
  # The Error: line for a toolkit name the parent doesn't know
150
225
  # (Peers#toolkit_names: its built-in, config and plugin kits), nil
151
226
  # when it knows it, none was given, or the peers can't tell (a bare
@@ -126,8 +126,7 @@ module Samagotchi
126
126
  # @param env [Hash] the XDG env the memories dir resolves from
127
127
  def self.scoped_index(scope, env: ENV)
128
128
  dir = memories_dir(scope, env: env)
129
- index_path = File.join(dir, "#{MEMORY_INDEX}.md")
130
- return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
129
+ return read_text(File.join(dir, "#{MEMORY_INDEX}.md")) if File.file?(File.join(dir, "#{MEMORY_INDEX}.md"))
131
130
 
132
131
  files = Dir.glob(File.join(dir, "*.md")).sort
133
132
  return "No memories stored yet." if files.empty?
@@ -135,6 +134,17 @@ module Samagotchi
135
134
  "Stored memories (no index yet):\n" + files.map { |f| File.basename(f, ".md") }.join("\n")
136
135
  end
137
136
 
137
+ # A text file from a memories dir, safe to match: a hand-edited index
138
+ # can hold a byte that isn't valid UTF-8, and a regex on it raises.
139
+ def self.read_text(path)
140
+ return "" unless File.file?(path)
141
+
142
+ text = File.read(path, encoding: "UTF-8")
143
+ text.valid_encoding? ? text : text.scrub
144
+ rescue StandardError
145
+ ""
146
+ end
147
+
138
148
  def self.memory_path(entry_name, scope)
139
149
  File.join(memories_dir(scope), "#{entry_name}.md")
140
150
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.145.0"
4
+ VERSION = "0.146.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1099,7 +1099,7 @@ module Samagotchi
1099
1099
 
1100
1100
  # Kits the web never offers: the participants' boundary, a delegate
1101
1101
  # child's, and ToolkitSuggest's defer sentinel.
1102
- PICKER_HIDDEN_KITS = [Toolkit::REVIEW, Toolkit::CHILD, "undecided"].freeze
1102
+ PICKER_HIDDEN_KITS = [Toolkit::REVIEW, Toolkit::CHILD, ToolkitSuggest::UNDECIDED].freeze
1103
1103
 
1104
1104
  # The start page's toolkit picker: {default:, suggest:, kits: [{name,
1105
1105
  # description?}]}. Built-in and config kits only (a bundle's load in