samagotchi 0.40.0 → 0.41.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 +54 -1
- data/README.md +1 -1
- data/bin/chi +19 -2
- data/docs/cli.md +25 -2
- data/docs/configuration.md +34 -3
- data/docs/guardrails.md +15 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +90 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -7
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +91 -16
- data/lib/samagotchi/events.rb +8 -1
- data/lib/samagotchi/guardrails/model_size.rb +3 -4
- data/lib/samagotchi/guardrails/rules.rb +27 -17
- data/lib/samagotchi/guardrails/targets.rb +40 -1
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/llm/errors.rb +33 -1
- data/lib/samagotchi/llm_context_command.rb +151 -0
- data/lib/samagotchi/llm_context_override.rb +180 -0
- data/lib/samagotchi/llm_context_strategy.rb +63 -13
- data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
- data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
- data/lib/samagotchi/model_match.rb +54 -0
- data/lib/samagotchi/model_notes.rb +202 -0
- data/lib/samagotchi/parent_report.rb +12 -2
- data/lib/samagotchi/plugin/context.rb +3 -2
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +3 -17
- data/lib/samagotchi/self_report.rb +15 -0
- data/lib/samagotchi/send_command.rb +22 -2
- data/lib/samagotchi/session.rb +29 -4
- data/lib/samagotchi/session_commands.rb +21 -1
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +26 -3
- data/lib/samagotchi/system_prompt.rb +66 -11
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +20 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +36 -0
- data/lib/samagotchi/terminal_ui.rb +16 -6
- data/lib/samagotchi/tool_runner.rb +26 -1
- data/lib/samagotchi/tools/memory.rb +70 -3
- data/lib/samagotchi/turn_flow.rb +36 -8
- data/lib/samagotchi/turn_note.rb +40 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -1
- data/lib/samagotchi/web/public/app.js +40 -6
- data/lib/samagotchi/web/public/ctx.js +28 -5
- data/lib/samagotchi/web/public/format.js +5 -2
- data/lib/samagotchi/web/public/index.html +12 -0
- data/lib/samagotchi/web/public/live_turn.js +1 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- data/lib/samagotchi/worker.rb +24 -18
- metadata +7 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "model_match"
|
|
6
|
+
require_relative "model_overlay"
|
|
7
|
+
require_relative "memory_paths"
|
|
8
|
+
require_relative "muted_memories"
|
|
9
|
+
require_relative "tools/memory"
|
|
10
|
+
require_relative "guardrails/model_size"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# Model notes: memories named `model_notes_<name>` whose first line says
|
|
14
|
+
# which models they are for, in ModelMatch's grammar:
|
|
15
|
+
#
|
|
16
|
+
# models: deepseek-*|*deepseek-v4*
|
|
17
|
+
# Working habits for this model: ...
|
|
18
|
+
#
|
|
19
|
+
# Every note matching the session's model is loaded into the system
|
|
20
|
+
# prompt (SystemPrompt#model_notes_section, after identity), stacked: the
|
|
21
|
+
# system scope's, then the project's, by name within a scope. Notes add
|
|
22
|
+
# habits; none overrides another. The body is read through memory_read,
|
|
23
|
+
# so a note's own model overlays are appended as for any memory; the
|
|
24
|
+
# models: line is left out. Their index lines are left out of the
|
|
25
|
+
# prompt's indexes (#filter_index): the body is already there.
|
|
26
|
+
module ModelNotes
|
|
27
|
+
PREFIX = Tools::MODEL_NOTES_PREFIX
|
|
28
|
+
SCOPES = %w[system project].freeze
|
|
29
|
+
# A note over this, or all of them over TOTAL_LIMIT, still loads but
|
|
30
|
+
# warns once: they cost every request.
|
|
31
|
+
NOTE_LIMIT = 1_500
|
|
32
|
+
TOTAL_LIMIT = 3_000
|
|
33
|
+
|
|
34
|
+
# name: the memory name; scope: "system" or "project"; body: what the
|
|
35
|
+
# prompt carries (overlays appended, the models: line dropped); chars:
|
|
36
|
+
# its length; digest: a short SHA-256 of it.
|
|
37
|
+
Note = Data.define(:name, :scope, :body, :chars, :digest)
|
|
38
|
+
|
|
39
|
+
@warned = Set.new
|
|
40
|
+
@warn_mutex = Mutex.new
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# The notes for a model.
|
|
45
|
+
# @param name [String, nil] the bare model id (no host prefix)
|
|
46
|
+
# @param key [String, nil] its model key (ModelOverlay.key_for)
|
|
47
|
+
# @param fallback_key [String, nil] the overlay key read when +key+ has
|
|
48
|
+
# none (SystemPrompt::ModelIdentity#fallback_key)
|
|
49
|
+
# @param muted [Array<String>] the session's muted names (MutedMemories)
|
|
50
|
+
# @param small [#call, nil] → whether the model is a small one; nil:
|
|
51
|
+
# guardrails.small_models (Guardrails::ModelSize)
|
|
52
|
+
# @return [Array<Note>]
|
|
53
|
+
def for(name:, key:, fallback_key: nil, muted: [], small: nil)
|
|
54
|
+
return [] if name.nil? || name.to_s.strip.empty?
|
|
55
|
+
|
|
56
|
+
small ||= -> { Guardrails::ModelSize.small?(name, key) }
|
|
57
|
+
small_once = memo(small)
|
|
58
|
+
seen = Set.new
|
|
59
|
+
notes = SCOPES.flat_map do |scope|
|
|
60
|
+
files(scope).filter_map do |path|
|
|
61
|
+
next unless seen.add?(File.expand_path(path))
|
|
62
|
+
|
|
63
|
+
note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
warn_sizes(notes)
|
|
67
|
+
notes
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# +text+ (+scope+'s memory index) without the lines naming a model note
|
|
71
|
+
# that loads for some model (its file in +scope+ has a `models:` line);
|
|
72
|
+
# the same object when it names none. A `model_notes_*` memory that
|
|
73
|
+
# isn't one keeps its line, so it doesn't vanish. +env+: the XDG env
|
|
74
|
+
# the scope's dir resolves from.
|
|
75
|
+
def filter_index(text, scope, env: ENV)
|
|
76
|
+
return text if text.nil? || !text.include?(PREFIX)
|
|
77
|
+
|
|
78
|
+
dir = MemoryPaths.scope_dir(scope, env: env)
|
|
79
|
+
names = files_in(dir).map { |path| File.basename(path) }
|
|
80
|
+
text.each_line.reject do |line|
|
|
81
|
+
name = MutedMemories.index_line_name(line).to_s
|
|
82
|
+
name.start_with?(PREFIX) && !name.include?(".") && names.include?("#{name}.md") &&
|
|
83
|
+
note_file?(File.join(dir, "#{name}.md"))
|
|
84
|
+
end.join
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whether +path+ is a model note's file: it has a models: first line.
|
|
88
|
+
def note_file?(path)
|
|
89
|
+
File.file?(path) && !ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets)).nil?
|
|
90
|
+
rescue StandardError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Forgets which warnings were given (specs).
|
|
95
|
+
def reset_warnings!
|
|
96
|
+
@warn_mutex.synchronize { @warned.clear }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def files(scope) = files_in(MemoryPaths.scope_dir(scope))
|
|
100
|
+
|
|
101
|
+
# The `model_notes_*.md` files in +dir+, by their names on disk: the
|
|
102
|
+
# prefix exactly as written (a case-insensitive disk's glob matches
|
|
103
|
+
# MODEL_NOTES_x.md too, which the guardrails would not see as a note).
|
|
104
|
+
def files_in(dir)
|
|
105
|
+
Dir.glob(File.join(dir, "#{PREFIX}*.md")).select { |path| File.basename(path).start_with?(PREFIX) }.sort
|
|
106
|
+
rescue StandardError
|
|
107
|
+
[]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
|
|
111
|
+
stem = File.basename(path, ".md")
|
|
112
|
+
return nil if dotted?(path, stem)
|
|
113
|
+
return nil if MutedMemories.muted?(stem, muted)
|
|
114
|
+
|
|
115
|
+
entries = models_of(path)
|
|
116
|
+
return nil unless entries
|
|
117
|
+
return nil unless ModelMatch.match?(entries, name: name, key: key, small: small)
|
|
118
|
+
|
|
119
|
+
body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
|
|
120
|
+
return nil if body.empty?
|
|
121
|
+
|
|
122
|
+
body = with_overlay(body, stem, scope, [key, fallback_key])
|
|
123
|
+
|
|
124
|
+
Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The note's model overlay appended as memory_read appends one: the
|
|
130
|
+
# key's, else the fallback key's. The file is read by its own path, not
|
|
131
|
+
# by name through memory_read (whose names are comma lists).
|
|
132
|
+
def with_overlay(body, stem, scope, keys)
|
|
133
|
+
keys.compact.each do |overlay_key|
|
|
134
|
+
overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
|
|
135
|
+
next unless overlay && File.file?(overlay)
|
|
136
|
+
|
|
137
|
+
return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
|
|
138
|
+
"#{File.read(overlay, encoding: "UTF-8").strip}"
|
|
139
|
+
end
|
|
140
|
+
body
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A model overlay of a note (`model_notes_a.<key>.md`) is read with
|
|
144
|
+
# its note; any other dotted name is skipped with a warning (memory_write
|
|
145
|
+
# refuses it: next to a base it would read as an overlay).
|
|
146
|
+
def dotted?(path, stem)
|
|
147
|
+
return false unless stem.include?(".")
|
|
148
|
+
|
|
149
|
+
unless ModelOverlay.overlay_file?(path)
|
|
150
|
+
warn_once("model_note_skipped", "Warning: model note #{path} skipped: a model note's name has no dot " \
|
|
151
|
+
"(rename it, e.g. #{Tools::MemoryWrite.undotted(stem)}.md)", path)
|
|
152
|
+
end
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The entries of the file's `models:` first line; nil (warned once)
|
|
157
|
+
# without one.
|
|
158
|
+
def models_of(path)
|
|
159
|
+
entries = ModelMatch.models_line(File.open(path, encoding: "UTF-8", &:gets))
|
|
160
|
+
return entries if entries
|
|
161
|
+
|
|
162
|
+
warn_once("model_note_skipped", "Warning: model note #{path} skipped: its first line must be " \
|
|
163
|
+
"`models: <glob>|small|…`", path)
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def warn_sizes(notes)
|
|
168
|
+
notes.each do |note|
|
|
169
|
+
next unless note.chars > NOTE_LIMIT
|
|
170
|
+
|
|
171
|
+
warn_once("model_notes_large", "Warning: model note #{note.name} (#{note.scope}) is #{note.chars} chars, over " \
|
|
172
|
+
"#{NOTE_LIMIT}: it is sent with every request", note.name)
|
|
173
|
+
end
|
|
174
|
+
total = notes.sum(&:chars)
|
|
175
|
+
return unless total > TOTAL_LIMIT
|
|
176
|
+
|
|
177
|
+
warn_once("model_notes_large", "Warning: the model notes for this model are #{total} chars, over #{TOTAL_LIMIT}: " \
|
|
178
|
+
"they are sent with every request", notes.map(&:name).join(","))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Once per message for the process (a prompt is built again on a
|
|
182
|
+
# model switch, another thinking level, changed tools).
|
|
183
|
+
def warn_once(event, echo, memory)
|
|
184
|
+
first = @warn_mutex.synchronize { @warned.add?(echo) }
|
|
185
|
+
Log.warn(:memory, event, echo: echo, memory: memory) if first
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def memo(callable)
|
|
189
|
+
value = nil
|
|
190
|
+
known = false
|
|
191
|
+
lambda do
|
|
192
|
+
unless known
|
|
193
|
+
value = callable.call
|
|
194
|
+
known = true
|
|
195
|
+
end
|
|
196
|
+
value
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private_class_method :note_file?, :files, :files_in, :note_at, :with_overlay, :dotted?, :models_of, :warn_sizes, :warn_once, :memo
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -94,7 +94,7 @@ module Samagotchi
|
|
|
94
94
|
# @return [Hash]
|
|
95
95
|
def stop_facts(result)
|
|
96
96
|
case status(result)
|
|
97
|
-
when "failed" then { error_kind: result.error_kind, retryable: result.retryable }.compact
|
|
97
|
+
when "failed" then { error_kind: result.error_kind, retryable: result.retryable, kept_steps: result.kept_steps }.compact
|
|
98
98
|
when "canceled" then { cancel_reason: result.cancel_reason, stopped_by: result.stopped_by }.compact
|
|
99
99
|
else {}
|
|
100
100
|
end
|
|
@@ -242,7 +242,7 @@ module Samagotchi
|
|
|
242
242
|
|
|
243
243
|
def no_reply_line(result, session_id = nil)
|
|
244
244
|
case result.outcome
|
|
245
|
-
when "failed" then result
|
|
245
|
+
when "failed" then failed_line(result)
|
|
246
246
|
when "canceled" then "the turn was canceled"
|
|
247
247
|
when "completed" then "the turn ended with no visible answer"
|
|
248
248
|
when "not_continued" then "the turn was not continued (Stop); its work so far stays"
|
|
@@ -253,6 +253,16 @@ module Samagotchi
|
|
|
253
253
|
end
|
|
254
254
|
end
|
|
255
255
|
|
|
256
|
+
# A failed turn's line; one whose work stayed says so, so the task isn't
|
|
257
|
+
# sent again over what it changed.
|
|
258
|
+
def failed_line(result)
|
|
259
|
+
why = result.text.to_s.strip
|
|
260
|
+
steps = result.kept_steps
|
|
261
|
+
return why.empty? ? "the turn failed" : "the turn failed: #{why}" unless steps.is_a?(Integer)
|
|
262
|
+
|
|
263
|
+
"the turn failed after #{steps} tool step#{"s" if steps != 1}#{": #{why}" unless why.empty?}; its work so far stays"
|
|
264
|
+
end
|
|
265
|
+
|
|
256
266
|
def first_line(text)
|
|
257
267
|
text.to_s.strip.lines.first.to_s.strip
|
|
258
268
|
end
|
|
@@ -20,12 +20,13 @@ module Samagotchi
|
|
|
20
20
|
# the answer text.
|
|
21
21
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
22
|
# +model_name+ (the session's resolved model ref, live after /model) and
|
|
23
|
-
# +state_dir+ are what ctx.sessions forks with
|
|
23
|
+
# +state_dir+ are what ctx.sessions forks with, and +llm_context+ (the
|
|
24
|
+
# session's own LLMContextOverride, nil without one), which a fork copies; +model_key+ is the
|
|
24
25
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
25
26
|
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
26
27
|
# (reason, label), each true when it acted on a running turn.
|
|
27
28
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
28
|
-
:ask_model, :model_name, :model_key, :state_dir, :scratch, :steer, :stop_turn, :stop_generation,
|
|
29
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
|
|
29
30
|
keyword_init: true)
|
|
30
31
|
|
|
31
32
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
@@ -32,6 +32,7 @@ module Samagotchi
|
|
|
32
32
|
# Start a child session from +messages+ (usually ctx.messages plus
|
|
33
33
|
# more). With no prompt the child waits idle for the user; with one it
|
|
34
34
|
# runs it as its first turn, and counts against session.max_children.
|
|
35
|
+
# It keeps this session's own llm_context values (Session#llm_context).
|
|
35
36
|
# Each image a message names is copied into the child, or dropped with
|
|
36
37
|
# a note in its message when its file is gone.
|
|
37
38
|
# @param messages [Array<Hash>] the child's conversation to start with
|
|
@@ -52,7 +53,8 @@ module Samagotchi
|
|
|
52
53
|
child = SessionManager.spawn_session(
|
|
53
54
|
prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
|
|
54
55
|
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
|
|
55
|
-
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir
|
|
56
|
+
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
|
|
57
|
+
llm_context: @host.llm_context&.call
|
|
56
58
|
)
|
|
57
59
|
child.id
|
|
58
60
|
end
|
|
@@ -45,7 +45,7 @@ module Samagotchi
|
|
|
45
45
|
# decided the outcome loaded it (a caller's next baseline); nil for
|
|
46
46
|
# :canceled and :timeout
|
|
47
47
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
48
|
-
:stopped_by, :session, keyword_init: true)
|
|
48
|
+
:stopped_by, :kept_steps, :session, keyword_init: true)
|
|
49
49
|
|
|
50
50
|
module_function
|
|
51
51
|
|
|
@@ -171,8 +171,8 @@ module Samagotchi
|
|
|
171
171
|
return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"], session: session)
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
175
|
-
why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
|
|
174
|
+
text = outcome == "failed" ? TurnNote.failure_summary(session.messages) : nil
|
|
175
|
+
why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by", "kept_steps").transform_keys(&:to_sym) : {}
|
|
176
176
|
Result.new(status: :no_reply, outcome: outcome, text: text, session: session, **why)
|
|
177
177
|
end
|
|
178
178
|
|
|
@@ -200,20 +200,6 @@ module Samagotchi
|
|
|
200
200
|
session
|
|
201
201
|
end
|
|
202
202
|
|
|
203
|
-
# The summary in the failed-turn note at the conversation's tail, nil
|
|
204
|
-
# without one.
|
|
205
|
-
def failure_summary(messages)
|
|
206
|
-
list = Array(messages)
|
|
207
|
-
index = TurnNote.trailing_index(list)
|
|
208
|
-
return nil unless index
|
|
209
|
-
|
|
210
|
-
text = (list[index][:content] || list[index]["content"]).to_s
|
|
211
|
-
prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
|
|
212
|
-
return nil unless text.start_with?(prefix)
|
|
213
|
-
|
|
214
|
-
text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
|
|
215
|
-
end
|
|
216
|
-
|
|
217
203
|
# @return [Result, nil] :done with the newest reply past the cursor
|
|
218
204
|
def reply_past(id, state_dir:, cursor:)
|
|
219
205
|
file = newest_reply(id, state_dir: state_dir)
|
|
@@ -20,6 +20,7 @@ require_relative "hooks/loader"
|
|
|
20
20
|
require_relative "memory_bundle/provenance"
|
|
21
21
|
require_relative "memory_bundle/manifest"
|
|
22
22
|
require_relative "memory_bundle/system_bundle"
|
|
23
|
+
require_relative "memory_bundle/index_size"
|
|
23
24
|
require_relative "desktop"
|
|
24
25
|
require_relative "live_versions"
|
|
25
26
|
require_relative "web/lan"
|
|
@@ -53,6 +54,7 @@ module Samagotchi
|
|
|
53
54
|
["hooks dir", hooks_dir(env)],
|
|
54
55
|
["memories", Tools::MemoryRead.memories_dir("system", env: env)],
|
|
55
56
|
["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
|
|
57
|
+
["memory index", memory_index_summary(env)],
|
|
56
58
|
["sessions", Session.default_state_dir(env: env)],
|
|
57
59
|
["log", log_path(env)],
|
|
58
60
|
["model", model ? model_label(model, default, env) : "(not configured)"],
|
|
@@ -100,6 +102,19 @@ module Samagotchi
|
|
|
100
102
|
LogPath.resolve(env: env) || "(disabled: log.disable)"
|
|
101
103
|
end
|
|
102
104
|
|
|
105
|
+
# "system ~2.0k tokens (54 lines), project ~1.4k tokens (35 lines)":
|
|
106
|
+
# the two indexes every prompt carries (before a session's mutes), a
|
|
107
|
+
# scope over memory.index_warn_tokens marked so. Two file reads. The
|
|
108
|
+
# indexes come from +env+'s memories dirs; the limit and
|
|
109
|
+
# context.chars_per_token from Config, which reads the process's ENV
|
|
110
|
+
# and config.yml as the rest of chi self's settings (web.port) do.
|
|
111
|
+
def memory_index_summary(env)
|
|
112
|
+
limit = MemoryBundle::IndexSize.warn_limit
|
|
113
|
+
%w[system project].map { |scope| MemoryBundle::IndexSize.of_scope(scope, env: env).summary(limit) }.join(", ")
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
"(unreadable: #{e.message})"
|
|
116
|
+
end
|
|
117
|
+
|
|
103
118
|
def with_presence(path)
|
|
104
119
|
File.exist?(path) ? path : "#{path} (missing)"
|
|
105
120
|
end
|
|
@@ -33,7 +33,7 @@ module Samagotchi
|
|
|
33
33
|
|
|
34
34
|
USAGE = <<~TEXT
|
|
35
35
|
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
36
|
-
chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
|
|
36
|
+
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
|
|
37
37
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
38
38
|
chi send --wait [--timeout S] [--format json] ID
|
|
39
39
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
@@ -53,6 +53,9 @@ module Samagotchi
|
|
|
53
53
|
--dir DIR (--new) its folder, the project it belongs to; default
|
|
54
54
|
the current one
|
|
55
55
|
--model M (--new) its model; default the configured one
|
|
56
|
+
--llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
|
|
57
|
+
(--new) its own LLM context strategy (none, stale,forget),
|
|
58
|
+
apply rule and budget (64k, off), as chi takes them
|
|
56
59
|
--wait wait for the answer and print it (one session); the
|
|
57
60
|
other lines go to stderr. Exit 3: it waits for an
|
|
58
61
|
answer from you: the question, its options and the
|
|
@@ -81,6 +84,9 @@ module Samagotchi
|
|
|
81
84
|
f.switch "--new"
|
|
82
85
|
f.value "--dir"
|
|
83
86
|
f.value "--model"
|
|
87
|
+
f.value "--llm-context", key: :llm_strategy
|
|
88
|
+
f.value "--llm-context-apply", key: :llm_apply
|
|
89
|
+
f.value "--llm-context-budget", key: :llm_budget
|
|
84
90
|
f.value "--image", key: :images, repeat: true
|
|
85
91
|
f.switch "--wait"
|
|
86
92
|
f.value "--timeout"
|
|
@@ -200,14 +206,27 @@ module Samagotchi
|
|
|
200
206
|
return new_options(options) if options[:new]
|
|
201
207
|
|
|
202
208
|
%i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
209
|
+
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
203
210
|
return usage_error("give session ids") if options[:ids].empty?
|
|
204
211
|
|
|
205
212
|
options
|
|
206
213
|
end
|
|
207
214
|
|
|
215
|
+
# The --llm-context* option keys, and their fields.
|
|
216
|
+
LLM_CONTEXT_FLAGS = { llm_strategy: "--llm-context", llm_apply: "--llm-context-apply",
|
|
217
|
+
llm_budget: "--llm-context-budget" }.freeze
|
|
218
|
+
LLM_CONTEXT_FIELDS = { llm_strategy: :strategy, llm_apply: :apply, llm_budget: :budget_tokens }.freeze
|
|
219
|
+
|
|
208
220
|
def new_options(options)
|
|
209
221
|
return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
|
|
210
222
|
|
|
223
|
+
words = LLM_CONTEXT_FIELDS.filter_map { |key, field| [field, options[key]] if options[key] }.to_h
|
|
224
|
+
begin
|
|
225
|
+
options[:llm_context] = LLMContextOverride.update(nil, words) unless words.empty?
|
|
226
|
+
rescue ArgumentError => e
|
|
227
|
+
return usage_error(e.message)
|
|
228
|
+
end
|
|
229
|
+
|
|
211
230
|
if options[:dir]
|
|
212
231
|
options[:dir] = File.expand_path(options[:dir])
|
|
213
232
|
return usage_error("no folder #{options[:dir]}") unless File.directory?(options[:dir])
|
|
@@ -246,7 +265,8 @@ module Samagotchi
|
|
|
246
265
|
begin
|
|
247
266
|
start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
248
267
|
session = SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
249
|
-
model_name: options[:model],
|
|
268
|
+
model_name: options[:model], llm_context: options[:llm_context],
|
|
269
|
+
state_dir: @state_dir)
|
|
250
270
|
rescue StandardError => e
|
|
251
271
|
error_line("chi send: could not start a session: #{e.message}")
|
|
252
272
|
return 1
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
8
|
require_relative "context_note"
|
|
9
|
+
require_relative "llm_context_override"
|
|
9
10
|
|
|
10
11
|
require_relative "paths"
|
|
11
12
|
require_relative "project_scope"
|
|
@@ -70,6 +71,26 @@ module Samagotchi
|
|
|
70
71
|
# delegate children's reports (ChildReports); context: one an attached
|
|
71
72
|
# context source's change started (Worker).
|
|
72
73
|
attr_accessor :last_turn
|
|
74
|
+
# The session's own llm_context values (LLMContextOverride: strategy,
|
|
75
|
+
# apply rule, budget), before the model's; nil when it has none.
|
|
76
|
+
# Saved, so a --resume and a respawned worker keep them; a plugin's
|
|
77
|
+
# fork copies them, a delegate child starts without.
|
|
78
|
+
attr_reader :llm_context
|
|
79
|
+
|
|
80
|
+
# @param value [LLMContextOverride, Hash, nil] an empty one is nil; a
|
|
81
|
+
# file's Hash keeps the fields it can't read (LLMContextOverride.unread),
|
|
82
|
+
# saved back as they were until the session sets its own
|
|
83
|
+
def llm_context=(value)
|
|
84
|
+
@llm_context_unread = LLMContextOverride.unread(value)
|
|
85
|
+
@llm_context = LLMContextOverride.from_file(value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The session file's "llm_context": the override's fields over the ones
|
|
89
|
+
# it couldn't read; nil when there are none.
|
|
90
|
+
def llm_context_file
|
|
91
|
+
saved = @llm_context_unread.merge(@llm_context&.to_file || {})
|
|
92
|
+
saved.empty? ? nil : saved
|
|
93
|
+
end
|
|
73
94
|
|
|
74
95
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
75
96
|
# left), e.g. "loop-guard"; nil when it ended otherwise. Both strings
|
|
@@ -97,7 +118,7 @@ module Samagotchi
|
|
|
97
118
|
first_preview: "", test_run: false, pending_question: nil,
|
|
98
119
|
used_memory_names: [], project_root: nil,
|
|
99
120
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
100
|
-
last_turn: nil, model_typed: nil, delegate: false)
|
|
121
|
+
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil)
|
|
101
122
|
@id = id
|
|
102
123
|
@metadata_version = metadata_version
|
|
103
124
|
@mode = mode
|
|
@@ -120,6 +141,7 @@ module Samagotchi
|
|
|
120
141
|
@scratch = !!scratch
|
|
121
142
|
@last_turn = last_turn
|
|
122
143
|
@delegate = !!delegate
|
|
144
|
+
self.llm_context = llm_context
|
|
123
145
|
@archived = false
|
|
124
146
|
end
|
|
125
147
|
|
|
@@ -170,7 +192,7 @@ module Samagotchi
|
|
|
170
192
|
# seed); [] by default
|
|
171
193
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
172
194
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
173
|
-
scratch: false, model_typed: nil, delegate: false)
|
|
195
|
+
scratch: false, model_typed: nil, delegate: false, llm_context: nil)
|
|
174
196
|
now = Time.now.iso8601(3)
|
|
175
197
|
resolved_test = if test_run.nil?
|
|
176
198
|
test_session_env?
|
|
@@ -194,7 +216,8 @@ module Samagotchi
|
|
|
194
216
|
muted_memory_names: muted_memory_names,
|
|
195
217
|
parent_id: parent_id,
|
|
196
218
|
scratch: scratch,
|
|
197
|
-
delegate: delegate
|
|
219
|
+
delegate: delegate,
|
|
220
|
+
llm_context: llm_context
|
|
198
221
|
)
|
|
199
222
|
end
|
|
200
223
|
|
|
@@ -249,7 +272,8 @@ module Samagotchi
|
|
|
249
272
|
"parent_id" => nil,
|
|
250
273
|
"scratch" => false,
|
|
251
274
|
"last_turn" => nil,
|
|
252
|
-
"delegate" => false
|
|
275
|
+
"delegate" => false,
|
|
276
|
+
"llm_context" => nil
|
|
253
277
|
}.freeze
|
|
254
278
|
|
|
255
279
|
# A session from a parsed session file. +messages+ false leaves the
|
|
@@ -368,6 +392,7 @@ module Samagotchi
|
|
|
368
392
|
when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
|
|
369
393
|
when "test_run" then !!@test_run
|
|
370
394
|
when "delegate" then @delegate
|
|
395
|
+
when "llm_context" then llm_context_file
|
|
371
396
|
when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
|
|
372
397
|
Array(instance_variable_get(:"@#{key}"))
|
|
373
398
|
else instance_variable_get(:"@#{key}")
|
|
@@ -9,10 +9,11 @@ require_relative "tools/builtins"
|
|
|
9
9
|
require_relative "tools/context_read"
|
|
10
10
|
require_relative "tools/peers"
|
|
11
11
|
require_relative "commands/registry"
|
|
12
|
+
require_relative "llm_context_command"
|
|
12
13
|
|
|
13
14
|
module Samagotchi
|
|
14
15
|
# The session commands a REPL and a session worker both run: /model,
|
|
15
|
-
# /models, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
|
|
16
|
+
# /models, /llm-context, /guardrails, /context, !rollback, !cmd and the answer to a continue offer. They act on
|
|
16
17
|
# the Engine and its TurnFlow; the host prints the result's output and
|
|
17
18
|
# runs a continue turn when asked to (#run never runs a turn).
|
|
18
19
|
#
|
|
@@ -26,6 +27,7 @@ module Samagotchi
|
|
|
26
27
|
GUARDRAILS_COMMAND = "/guardrails"
|
|
27
28
|
CONTEXT_COMMAND = "/context"
|
|
28
29
|
HELP_COMMAND = "/help"
|
|
30
|
+
LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
|
|
29
31
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
30
32
|
# shows this many per host; /models <text> lists every match.
|
|
31
33
|
MODELS_PER_HOST = 20
|
|
@@ -73,6 +75,9 @@ module Samagotchi
|
|
|
73
75
|
end
|
|
74
76
|
registry.register(MODEL_COMMAND, "show or switch the model",
|
|
75
77
|
match: ->(text) { text.match?(%r{\A/model(?:\s+.*)?\z}) }) { |text| model(text) }
|
|
78
|
+
# Not anytime: it saves the session; it takes effect at the next turn's start.
|
|
79
|
+
registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
|
|
80
|
+
id: :llm_context, match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
|
|
76
81
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
77
82
|
registry.register("/stats", "show the session's stats", local: true)
|
|
78
83
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -330,6 +335,21 @@ module Samagotchi
|
|
|
330
335
|
@save.call(session)
|
|
331
336
|
end
|
|
332
337
|
|
|
338
|
+
# /llm-context (LLMContextCommand); :llm_context in changed when it set
|
|
339
|
+
# the session's values.
|
|
340
|
+
def llm_context(text)
|
|
341
|
+
output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_llm_context(session) })
|
|
342
|
+
.run(text.delete_prefix(LLM_CONTEXT_COMMAND))
|
|
343
|
+
reply(output, changed: changed ? [:llm_context] : [])
|
|
344
|
+
rescue ArgumentError => e
|
|
345
|
+
reply(e.message, status: :error)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def save_llm_context(session)
|
|
349
|
+
@engine.store_model!(session)
|
|
350
|
+
@save.call(session)
|
|
351
|
+
end
|
|
352
|
+
|
|
333
353
|
def model(text)
|
|
334
354
|
output, switched = model_command(text)
|
|
335
355
|
reply(output, changed: switched ? [:model] : [])
|
|
@@ -129,11 +129,13 @@ module Samagotchi
|
|
|
129
129
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
130
130
|
# @param title [String, nil] what the lists show before the first turn
|
|
131
131
|
# (the prompt's preview by default, else the seed's first user message)
|
|
132
|
+
# @param llm_context [LLMContextOverride, nil] the session's own
|
|
133
|
+
# llm_context values (chi --llm-context; a fork copies its parent's)
|
|
132
134
|
# @return [Session] with #seed_images_dropped: refs whose file was gone,
|
|
133
135
|
# and #model_warning: an id its host's saved list doesn't have
|
|
134
136
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
135
137
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
136
|
-
title: nil, delegate: false)
|
|
138
|
+
title: nil, delegate: false, llm_context: nil)
|
|
137
139
|
sd = state_dir || Session.default_state_dir
|
|
138
140
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
139
141
|
# alias is retargeted), with the name as typed beside it. A wrong host
|
|
@@ -153,7 +155,8 @@ module Samagotchi
|
|
|
153
155
|
muted_memory_names: muted_memories,
|
|
154
156
|
parent_id: parent_id,
|
|
155
157
|
messages: messages,
|
|
156
|
-
delegate: delegate
|
|
158
|
+
delegate: delegate,
|
|
159
|
+
llm_context: llm_context
|
|
157
160
|
)
|
|
158
161
|
session.model_warning = model_warning
|
|
159
162
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
@@ -53,8 +53,14 @@ module Samagotchi
|
|
|
53
53
|
# had none) and the session's tokens block as the snapshot has it.
|
|
54
54
|
GenerationReport = Data.define(:speed, :tokens)
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
# memory_index: the memory indexes the session's prompt held
|
|
57
|
+
# ({ system: { tokens:, lines: }, project: … }, string keys as saved),
|
|
58
|
+
# nil when an older file has none.
|
|
59
|
+
SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum,
|
|
60
|
+
:memory_index) do
|
|
61
|
+
def initialize(memory_index: nil, **) = super
|
|
62
|
+
|
|
63
|
+
def tokens = to_h.except(:ctx_pct, :memory_index)
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
# Per-turn transient state, reset on each turn_started.
|
|
@@ -150,7 +156,8 @@ module Samagotchi
|
|
|
150
156
|
count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
|
|
151
157
|
SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.call("prompt_sum"),
|
|
152
158
|
completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
|
|
153
|
-
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum")
|
|
159
|
+
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
|
|
160
|
+
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
|
|
154
161
|
rescue JSON::ParserError, SystemCallError, TypeError
|
|
155
162
|
nil
|
|
156
163
|
end
|
|
@@ -196,6 +203,7 @@ module Samagotchi
|
|
|
196
203
|
@wall_clock = wall_clock || -> { Time.now }
|
|
197
204
|
@session_id = nil
|
|
198
205
|
@state_dir = nil
|
|
206
|
+
@memory_index = nil
|
|
199
207
|
@started_at = nil
|
|
200
208
|
@last_activity_at = nil
|
|
201
209
|
@turn = nil
|
|
@@ -217,6 +225,14 @@ module Samagotchi
|
|
|
217
225
|
end
|
|
218
226
|
end
|
|
219
227
|
|
|
228
|
+
# The memory indexes the session's prompt holds (SystemPrompt#memory_index:
|
|
229
|
+
# { system: { tokens:, lines: }, project: … }), set by the Engine when
|
|
230
|
+
# it builds the prompt it sends; the snapshot and analytics.json carry it.
|
|
231
|
+
# @param block [Hash, nil]
|
|
232
|
+
def memory_index=(block)
|
|
233
|
+
@mutex.synchronize { @memory_index = block if block }
|
|
234
|
+
end
|
|
235
|
+
|
|
220
236
|
# The sessions dir analytics.json is read from and saved to (nil: the
|
|
221
237
|
# XDG default). Set it before #session_id=.
|
|
222
238
|
attr_writer :state_dir
|
|
@@ -367,6 +383,7 @@ module Samagotchi
|
|
|
367
383
|
cancellations: totals[:cancellations],
|
|
368
384
|
tokens: tokens_block(totals, turn),
|
|
369
385
|
context: context_block(@turn_records),
|
|
386
|
+
memory_index: @memory_index,
|
|
370
387
|
profile: @profile,
|
|
371
388
|
profile_source: @profile_source,
|
|
372
389
|
served_model: @served_model,
|
|
@@ -875,6 +892,8 @@ module Samagotchi
|
|
|
875
892
|
@served_model = prior["served_model"]
|
|
876
893
|
@served_model_for = prior["served_model_for"]
|
|
877
894
|
end
|
|
895
|
+
# The indexes the prompt last held, until this process builds its own.
|
|
896
|
+
@memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
|
|
878
897
|
# The window last seen, until this process's first generation reports.
|
|
879
898
|
window = prior["context"].is_a?(Hash) ? prior["context"] : {}
|
|
880
899
|
if window["window_tokens"] && @context_window_tokens.nil?
|
|
@@ -885,6 +904,10 @@ module Samagotchi
|
|
|
885
904
|
nil
|
|
886
905
|
end
|
|
887
906
|
|
|
907
|
+
def symbolized(hash)
|
|
908
|
+
hash.to_h { |key, value| [key.to_sym, value.is_a?(Hash) ? symbolized(value) : value] }
|
|
909
|
+
end
|
|
910
|
+
|
|
888
911
|
def loaded_records(records)
|
|
889
912
|
Array(records).filter_map { |record| record.transform_keys(&:to_sym) if record.is_a?(Hash) && record["id"] }
|
|
890
913
|
end
|