samagotchi 0.145.0 → 0.147.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -1
  3. data/docs/architecture.md +14 -5
  4. data/docs/cli.md +6 -3
  5. data/docs/configuration.md +16 -3
  6. data/docs/guardrails.md +7 -2
  7. data/docs/internals/bridge.md +3 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/web-frontend.md +3 -2
  10. data/docs/memory.md +45 -0
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/child_reports.rb +13 -6
  14. data/lib/samagotchi/guardrails/approval.rb +17 -4
  15. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -1
  16. data/lib/samagotchi/guardrails/verdict.rb +5 -3
  17. data/lib/samagotchi/memory_command.rb +135 -7
  18. data/lib/samagotchi/memory_paths.rb +9 -0
  19. data/lib/samagotchi/memory_usage.rb +368 -0
  20. data/lib/samagotchi/relay.rb +2 -2
  21. data/lib/samagotchi/tool_activity.rb +3 -3
  22. data/lib/samagotchi/tool_output_cut.rb +12 -1
  23. data/lib/samagotchi/toolkit_suggest.rb +7 -4
  24. data/lib/samagotchi/tools/delegate.rb +88 -13
  25. data/lib/samagotchi/tools/memory.rb +12 -2
  26. data/lib/samagotchi/version.rb +1 -1
  27. data/lib/samagotchi/web/app.rb +2 -2
  28. data/lib/samagotchi/web/auto_update.rb +60 -5
  29. data/lib/samagotchi/web/public/app.js +11 -18
  30. data/lib/samagotchi/web/public/participant_annotate.js +2 -1
  31. data/lib/samagotchi/web/public/question_cards.js +33 -9
  32. data/lib/samagotchi/web/public/revise_stack.js +6 -5
  33. data/lib/samagotchi/web/public/update_notice.js +22 -11
  34. data/lib/samagotchi/web/public/update_sheet.js +4 -1
  35. data/lib/samagotchi/web/public/verify_preset.js +5 -1
  36. data/lib/samagotchi/web/public/worker_features.js +14 -0
  37. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  38. data/lib/samagotchi/web/session_hub.rb +17 -4
  39. data/lib/samagotchi/working_set.rb +3 -2
  40. metadata +3 -1
@@ -5,42 +5,59 @@ require_relative "cli/command"
5
5
  require_relative "cli/exit"
6
6
  require_relative "cli/flags"
7
7
  require_relative "memory_paths"
8
+ require_relative "system_prompt"
8
9
  require_relative "tools/memory"
9
10
  require_relative "memory_bundle/index_updater"
10
11
  require_relative "memory_bundle/provenance"
12
+ require_relative "memory_usage"
11
13
 
12
14
  module Samagotchi
13
15
  # `chi memory`: the user's side of the memories (docs/memory.md). ls lists
14
16
  # both scopes' entries from their index.md lines and files, as memory_read
15
17
  # and the prompt see them; rm removes one as memory_write remove: true does
16
18
  # (Tools::MemoryWrite.remove: the file and its model overlays into the
17
- # bundle trash, the index line dropped, a bundle's memory refused).
19
+ # bundle trash, the index line dropped, a bundle's memory refused); usage
20
+ # reports what the memories cost and what the saved sessions read
21
+ # (MemoryUsage, docs/memory.md "What the memories cost").
18
22
  class MemoryCommand
19
23
  include CLI::Command
20
24
 
21
25
  USAGE = <<~TEXT
22
- Usage: chi memory <ls|rm> [options]
26
+ Usage: chi memory <ls|rm|usage> [options]
23
27
  ls [--scope project|system] [--format text|json] the memories: both scopes (this folder's project
24
28
  first), or one; each with its size, date, the bundle it came from and its index description
25
29
  rm NAME [--scope project|system] remove a memory: its file and its model overlays (NAME.<key>.md)
26
30
  move to the bundle trash (chi bundle trash lists and empties it) and its index line is dropped.
27
31
  Without --scope NAME must be in one scope only. A bundle's memory is refused: chi bundle
28
32
  uninstall removes it. NAME.<key> removes one model overlay alone.
33
+ usage [--scope project|system] [--top N] [--format text|json] what the memories cost, counted from
34
+ the saved sessions: each scope's index tokens, the memories read most (by tokens), and the ones
35
+ no saved session read. --top is the rows per section (10, at most 1000); no sessions says so and exits 0.
29
36
  TEXT
30
37
 
31
- SUBCOMMANDS = %w[ls rm].freeze
38
+ SUBCOMMANDS = %w[ls rm usage].freeze
32
39
  SCOPES = Tools::VALID_SCOPES
33
40
  # The description column's width in ls's text form (json has it all).
34
41
  DESCRIPTION_WIDTH = 60
35
42
  # ls's size column, right-aligned.
36
43
  SIZE_COLUMN = 2
44
+ # usage's rows per section, when --top is not given.
45
+ DEFAULT_TOP = 10
46
+ # The most rows --top may ask for: a report of more is unreadable, and a
47
+ # huge number used to raise out of Array#first.
48
+ TOP_LIMIT = 1000
37
49
 
38
50
  FLAGS = {
39
51
  "ls" => CLI::Flags.new(help: HELP_WORDS) do |f|
40
52
  f.value "--scope"
41
53
  f.value "--format"
42
54
  end,
43
- "rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--scope" }
55
+ "rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--scope" },
56
+ "usage" => CLI::Flags.new(help: HELP_WORDS) do |f|
57
+ f.value "--scope"
58
+ f.value "--top"
59
+ f.value "--format"
60
+ end
44
61
  }.freeze
45
62
 
46
63
  # One memory of a scope. +date+ is the index line's (the file's mtime
@@ -94,7 +111,7 @@ module Samagotchi
94
111
  return [] unless File.directory?(dir)
95
112
 
96
113
  index_path = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
97
- index_text = File.file?(index_path) ? File.read(index_path, encoding: "UTF-8") : ""
114
+ index_text = Tools::MemoryRead.read_text(index_path)
98
115
  # name => its line; the first one when a name has two. A name rm
99
116
  # would refuse (a path: "../x") isn't listed.
100
117
  lines = {}
@@ -191,6 +208,118 @@ module Samagotchi
191
208
 
192
209
  def clip(text) = text.length > DESCRIPTION_WIDTH ? "#{text[0, DESCRIPTION_WIDTH - 1]}…" : text
193
210
 
211
+ def run_usage(options, args)
212
+ return usage_error("usage takes no NAME (unexpected #{args.first})") unless args.empty?
213
+
214
+ format = options[:format]
215
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
216
+ return usage_error("--top must be a positive integer") unless options[:top].nil? || options[:top].to_s.match?(/\A[1-9]\d*\z/)
217
+ return usage_error("--top must be at most #{TOP_LIMIT}") if options[:top].to_i > TOP_LIMIT
218
+
219
+ sessions, unreadable = MemoryUsage.load_sessions
220
+ report = MemoryUsage.new(
221
+ sessions: sessions, entries: self.class.entries(options[:scope]),
222
+ scopes: options[:scope] ? [options[:scope]] : SCOPES, unreadable: unreadable, root: Dir.pwd,
223
+ # config.yml's memories: plus the system memories every prompt carries
224
+ # (SystemPrompt::DEFAULT_SYSTEM_MEMORIES): both are in the prompt from
225
+ # the start, so no read is needed for them.
226
+ baseline: ConfigFile.preloaded_memories + SystemPrompt::DEFAULT_SYSTEM_MEMORIES
227
+ ).report(top: (options[:top] || DEFAULT_TOP).to_i)
228
+
229
+ if format == "json"
230
+ @stdout.puts(JSON.pretty_generate(report.to_h))
231
+ else
232
+ print_usage(report)
233
+ end
234
+ CLI::Exit::OK
235
+ end
236
+
237
+ def print_usage(report)
238
+ if report.sessions.zero?
239
+ why = []
240
+ why << "#{report.unreadable} #{report.unreadable == 1 ? "file" : "files"} that could not be read" if report.unreadable.positive?
241
+ why << "#{report.skipped} #{report.skipped == 1 ? "session" : "sessions"} not counted (test runs or no user message)" if report.skipped.positive?
242
+ @stdout.puts(why.empty? ? "no sessions to count: nothing saved in #{Session.default_state_dir}" : "no sessions to count: #{why.join(" and ")} in #{Session.default_state_dir}")
243
+ return
244
+ end
245
+
246
+ header = "#{report.sessions} #{report.sessions == 1 ? "session" : "sessions"} over " \
247
+ "#{report.days} #{report.days == 1 ? "day" : "days"}"
248
+ header += ", #{report.unreadable} unreadable #{report.unreadable == 1 ? "file" : "files"} skipped" if report.unreadable.positive?
249
+ header += ", #{report.skipped} #{report.skipped == 1 ? "session" : "sessions"} not counted (test run or no user message)" if report.skipped.positive?
250
+ @stdout.puts(header)
251
+ @stdout.puts
252
+
253
+ # The index comes first: it is paid on every prompt, reads only on a read.
254
+ print_table(["Indexes", "tokens", "limit", "index lines naming a memory no session read"],
255
+ report.indexes.map do |index|
256
+ [indent(index.scope), token_text(index.tokens), index.limit.positive? ? index.limit.to_s : "off",
257
+ "#{index.unread_lines} of #{index.lines} (#{index.unread_percent}%)"]
258
+ end, right: [1, 2])
259
+ @stdout.puts
260
+
261
+ if report.read_count.zero?
262
+ @stdout.puts("No memory reads in these sessions.")
263
+ elsif report.top.empty?
264
+ # Every read is of a name with no memory behind it: no rows to show.
265
+ @stdout.puts("Most read, by tokens: none of these reads names a memory still there " \
266
+ "(#{report.unknown_names} #{report.unknown_names == 1 ? "name" : "names"} read, " \
267
+ "#{token_text(report.read_tokens)} tokens)")
268
+ else
269
+ share = report.read_tokens.positive? ? (100.0 * report.top.sum(&:tokens) / report.read_tokens).round : 0
270
+ @stdout.puts("Most read, by tokens (these #{report.top.size} of #{report.read_names}: " \
271
+ "#{share}% of #{token_text(report.read_tokens)} read tokens)")
272
+ print_table(%w[Memory tokens reads size scope from],
273
+ report.top.map do |use|
274
+ [indent(use.name), token_text(use.tokens), use.reads.to_s,
275
+ size_text(use.size_tokens), use.scope, use.source.to_s]
276
+ end, right: [1, 2])
277
+ if report.cut_reads.positive?
278
+ @stdout.puts(indent("note: #{report.cut_reads} of these reads came back cut (reads used to be cut), " \
279
+ "so a big memory may cost more per read now than these numbers show"))
280
+ end
281
+ end
282
+ @stdout.puts
283
+
284
+ if report.not_read_total.zero?
285
+ @stdout.puts("Not read in saved sessions: nothing of your own to count (a bundle's memories are not listed)")
286
+ else
287
+ @stdout.puts("Not read in saved sessions: #{report.not_read_count} of #{report.not_read_total} of your own " \
288
+ "memories (a bundle's are not listed)")
289
+ print_table(["", "size", "scope"], report.not_read.map { |entry| [indent(entry.name), size_text(entry.tokens), entry.scope] }, right: [1]) if report.not_read.any?
290
+ @stdout.puts(indent("An index line is paid whatever a session reads: a memory nobody opens still costs its line in every prompt."))
291
+ end
292
+
293
+ @stdout.puts(indent("Names read that have no memory now: #{report.unknown_names}")) if report.unknown_names.positive?
294
+ end
295
+
296
+ def indent(text) = " #{text}"
297
+
298
+ # A token count as usage's columns show it: "450", "1.9k", "2.0M".
299
+ def token_text(value)
300
+ number = value.to_i
301
+ return number.to_s if number < 1000
302
+ return format("%.1fk", number / 1000.0) if number < 1_000_000
303
+
304
+ format("%.1fM", number / 1_000_000.0)
305
+ end
306
+
307
+ # A size column: "-" when the memory has no file to measure.
308
+ def size_text(tokens) = tokens.nil? ? "-" : token_text(tokens)
309
+
310
+ # A header row and its rows, two spaces apart; the +right+ columns align on
311
+ # their numbers.
312
+ def print_table(header, rows, right: [])
313
+ table = [header, *rows]
314
+ widths = table.transpose.map { |column| column.map { |cell| cell.to_s.length }.max }
315
+ table.each do |cells|
316
+ line = cells.each_with_index.map do |cell, index|
317
+ right.include?(index) ? cell.to_s.rjust(widths[index]) : cell.to_s.ljust(widths[index])
318
+ end
319
+ @stdout.puts(line.reject.with_index { |_, index| widths[index].zero? }.join(" ").rstrip)
320
+ end
321
+ end
322
+
194
323
  def run_rm(options, args)
195
324
  name = args.shift
196
325
  return usage_error("give the memory's NAME") unless name
@@ -229,9 +358,8 @@ module Samagotchi
229
358
 
230
359
  def present?(name, scope)
231
360
  dir = MemoryPaths.scope_dir(scope)
232
- index = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
233
361
  File.file?(File.join(dir, "#{name}.md")) ||
234
- (File.file?(index) && File.read(index, encoding: "UTF-8").match?(Tools::MemoryWrite.managed_pattern(name)))
362
+ Tools::MemoryRead.read_text(File.join(dir, "#{Tools::MEMORY_INDEX}.md")).match?(Tools::MemoryWrite.managed_pattern(name))
235
363
  end
236
364
  end
237
365
  end
@@ -51,6 +51,15 @@ module Samagotchi
51
51
  File.basename(common) == ".git" ? File.dirname(common) : common
52
52
  end
53
53
 
54
+ # The repository's common git dir of +cwd+ as an absolute real path (the
55
+ # same for every worktree of it), or nil outside any repository. Reads
56
+ # files only, no git process; #project_root turns it into a checkout
57
+ # folder, which a bare layout's container is not.
58
+ def common_git_dir(cwd = Dir.pwd)
59
+ gitdir = git_dir(cwd)
60
+ gitdir && File.realpath(common_dir(gitdir))
61
+ end
62
+
54
63
  # The top of the git work tree holding +cwd+: the directory with the
55
64
  # nearest .git (a linked worktree's own checkout, unlike #project_root);
56
65
  # nil outside any repository.
@@ -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