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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -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 +3 -2
- 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 +6 -2
- data/lib/samagotchi/web/public/participant_annotate.js +2 -1
- data/lib/samagotchi/web/public/question_cards.js +33 -9
- data/lib/samagotchi/web/public/revise_stack.js +6 -5
- 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
|
@@ -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
|
|
@@ -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,
|
|
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[
|
|
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) ==
|
|
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 ==
|
|
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,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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,
|
|
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
|