samagotchi 0.144.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +7 -2
- data/docs/internals/bridge.md +3 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/web-frontend.md +24 -3
- data/docs/memory.md +45 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +13 -6
- data/lib/samagotchi/guardrails/approval.rb +17 -4
- data/lib/samagotchi/guardrails/parent_approvals.rb +5 -1
- data/lib/samagotchi/guardrails/verdict.rb +5 -3
- data/lib/samagotchi/memory_command.rb +135 -7
- data/lib/samagotchi/memory_paths.rb +9 -0
- data/lib/samagotchi/memory_usage.rb +368 -0
- data/lib/samagotchi/relay.rb +2 -2
- data/lib/samagotchi/tool_activity.rb +3 -3
- data/lib/samagotchi/tool_output_cut.rb +12 -1
- data/lib/samagotchi/toolkit_suggest.rb +7 -4
- data/lib/samagotchi/tools/delegate.rb +88 -13
- data/lib/samagotchi/tools/memory.rb +12 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/app.js +10 -2
- data/lib/samagotchi/web/public/composer_fold.js +5 -2
- data/lib/samagotchi/web/public/composer_grip.js +45 -2
- data/lib/samagotchi/web/public/index.html +8 -6
- data/lib/samagotchi/web/public/info_bar_model.js +6 -4
- data/lib/samagotchi/web/public/participant_annotate.js +2 -1
- data/lib/samagotchi/web/public/question_cards.js +59 -12
- data/lib/samagotchi/web/public/revise_stack.js +6 -5
- data/lib/samagotchi/web/public/stage_frame.js +19 -7
- data/lib/samagotchi/web/public/stage_model.js +2 -1
- data/lib/samagotchi/web/public/stage_view.js +39 -24
- data/lib/samagotchi/web/public/verify_preset.js +5 -1
- data/lib/samagotchi/web/public/worker_features.js +14 -0
- data/lib/samagotchi/working_set.rb +3 -2
- 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 =
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -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" ?
|
|
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
|
|
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 =
|
|
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
|