samagotchi 0.40.0 → 0.42.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 +82 -1
- data/README.md +1 -1
- data/bin/chi +19 -2
- data/docs/cli.md +32 -7
- data/docs/configuration.md +34 -3
- data/docs/guardrails.md +15 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +108 -1
- data/docs/sessions.md +5 -5
- data/docs/testing.md +16 -0
- 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/child_reports.rb +8 -4
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_command.rb +5 -2
- data/lib/samagotchi/engine.rb +121 -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 +205 -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/prompt_note.rb +53 -0
- data/lib/samagotchi/reply_wait.rb +3 -17
- data/lib/samagotchi/self_report.rb +47 -0
- data/lib/samagotchi/send_command.rb +22 -2
- data/lib/samagotchi/session.rb +44 -4
- data/lib/samagotchi/session_commands.rb +45 -6
- data/lib/samagotchi/session_manager.rb +5 -2
- data/lib/samagotchi/session_metrics.rb +47 -7
- data/lib/samagotchi/system_prompt.rb +79 -11
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +32 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -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 +44 -8
- data/lib/samagotchi/turn_note.rb +40 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -6
- data/lib/samagotchi/web/public/app.js +47 -7
- data/lib/samagotchi/web/public/ctx.js +28 -5
- data/lib/samagotchi/web/public/format.js +29 -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 +6 -0
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_view.js +15 -2
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +36 -18
- data/lib/samagotchi/worker_wakes.rb +2 -1
- metadata +8 -1
|
@@ -7,14 +7,16 @@ require_relative "memory_paths"
|
|
|
7
7
|
require_relative "tool_declarations"
|
|
8
8
|
require_relative "tools/memory"
|
|
9
9
|
require_relative "muted_memories"
|
|
10
|
+
require_relative "model_notes"
|
|
10
11
|
require_relative "bundle_needs"
|
|
12
|
+
require_relative "memory_bundle/index_size"
|
|
11
13
|
require_relative "thinking"
|
|
12
14
|
|
|
13
15
|
module Samagotchi
|
|
14
16
|
# The system prompt an Engine gives its model: the base prompt (tool
|
|
15
17
|
# declarations and call syntax for the raw-prompt loop, none for the chat
|
|
16
18
|
# loop, the shared guidance) and around it the thinking token, rg
|
|
17
|
-
# guidance, the identity memory, the preloaded memories, AGENT.md, the
|
|
19
|
+
# guidance, the identity memory, the model notes, the preloaded memories, AGENT.md, the
|
|
18
20
|
# memory indexes and, last, the model, where the session runs and the
|
|
19
21
|
# session (#system_prompt_with_index has the order and why).
|
|
20
22
|
#
|
|
@@ -45,11 +47,15 @@ module Samagotchi
|
|
|
45
47
|
# @param session [#call] → Session, nil
|
|
46
48
|
# @param thinking [#call] → Symbol, the effective model's level (Thinking)
|
|
47
49
|
# @param model [#call] → ModelIdentity, nil (the section is left out)
|
|
50
|
+
# @param model_notes [#call] → Array<ModelNotes::Note>, the effective
|
|
51
|
+
# model's (none: the section is left out)
|
|
48
52
|
# @param memories [Array<String>] the --memory list
|
|
49
53
|
# @param muted_memory_names [Array<String>] normalized (MutedMemories)
|
|
50
|
-
def initialize(profile:, tools:, session:, thinking:, model: -> {},
|
|
54
|
+
def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
|
|
55
|
+
muted_memory_names: [])
|
|
51
56
|
@profile_lookup = profile
|
|
52
57
|
@model_lookup = model
|
|
58
|
+
@model_notes_lookup = model_notes
|
|
53
59
|
@tools_lookup = tools
|
|
54
60
|
@session_lookup = session
|
|
55
61
|
@thinking_lookup = thinking
|
|
@@ -71,16 +77,44 @@ module Samagotchi
|
|
|
71
77
|
# @param layers [Array<Symbol>] the turn's LLM context layers: the
|
|
72
78
|
# native prompt declares the tools they add (Tools::Registry#entries)
|
|
73
79
|
def build(chat: false, thinking: nil, layers: [])
|
|
80
|
+
key = [chat, thinking, layers]
|
|
74
81
|
@built ||= {}
|
|
75
|
-
@
|
|
76
|
-
|
|
77
|
-
)
|
|
82
|
+
@index_sizes ||= {}
|
|
83
|
+
@notes_loaded ||= {}
|
|
84
|
+
unless @built.key?(key)
|
|
85
|
+
@section_notes = nil
|
|
86
|
+
@built[key] = system_prompt_with_index(
|
|
87
|
+
assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
|
|
88
|
+
)
|
|
89
|
+
@index_sizes[key] = @measured_index
|
|
90
|
+
@notes_loaded[key] = @section_notes || []
|
|
91
|
+
end
|
|
92
|
+
@memory_index = @index_sizes[key]
|
|
93
|
+
@prompt_notes = @notes_loaded[key]
|
|
94
|
+
@built[key]
|
|
78
95
|
end
|
|
79
96
|
|
|
97
|
+
# The memory indexes the prompt #build returned last holds, per scope
|
|
98
|
+
# ({ system: { tokens:, lines: }, project: { … } }; MemoryBundle::IndexSize
|
|
99
|
+
# of the text after the session's mutes and the model notes' lines), nil
|
|
100
|
+
# before a build. Measured when the prompt is built: a memory write
|
|
101
|
+
# during the session changes neither the prompt nor this.
|
|
102
|
+
# @return [Hash, nil]
|
|
103
|
+
attr_reader :memory_index
|
|
104
|
+
|
|
105
|
+
# The model notes the prompt #build returned last carries
|
|
106
|
+
# (Array<ModelNotes::Note>, [] with none), nil before a build and after
|
|
107
|
+
# #reset!. Read when the prompt is built, as the section is.
|
|
108
|
+
# @return [Array<ModelNotes::Note>, nil]
|
|
109
|
+
attr_reader :prompt_notes
|
|
110
|
+
|
|
80
111
|
# Drops the built prompts: the next #build reads the profile, tools,
|
|
81
112
|
# indexes and memories again (a model or profile switch, changed tools).
|
|
82
113
|
def reset!
|
|
83
114
|
@built = nil
|
|
115
|
+
@index_sizes = nil
|
|
116
|
+
@notes_loaded = nil
|
|
117
|
+
@prompt_notes = nil
|
|
84
118
|
@stable_lengths = nil
|
|
85
119
|
end
|
|
86
120
|
|
|
@@ -254,11 +288,13 @@ module Samagotchi
|
|
|
254
288
|
# still follow them).
|
|
255
289
|
def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
|
|
256
290
|
thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
|
|
291
|
+
indexes = { project: read_memory_index("project"), system: read_memory_index("system") }
|
|
292
|
+
@measured_index = measure_indexes(indexes)
|
|
257
293
|
memory_sections = [
|
|
258
|
-
"Project memories:\n#{
|
|
259
|
-
"System memories:\n#{
|
|
294
|
+
"Project memories:\n#{indexes[:project]}",
|
|
295
|
+
"System memories:\n#{indexes[:system]}"
|
|
260
296
|
].join("\n\n")
|
|
261
|
-
stable = [thinking_token + base, rg_guidance, system_identity_section, explicit_memory_section,
|
|
297
|
+
stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
|
|
262
298
|
project_specific_description, memory_sections]
|
|
263
299
|
stable_text = stable.compact.join("\n")
|
|
264
300
|
volatile = [current_model, project_location, current_session].compact
|
|
@@ -295,6 +331,23 @@ module Samagotchi
|
|
|
295
331
|
nil
|
|
296
332
|
end
|
|
297
333
|
|
|
334
|
+
# The model notes for the session's model (ModelNotes), right after
|
|
335
|
+
# identity: a heading per scope, each note under its name, the models:
|
|
336
|
+
# line left out. nil without any, so the prompt stays as it was.
|
|
337
|
+
def model_notes_section
|
|
338
|
+
@section_notes = []
|
|
339
|
+
notes = Array(@model_notes_lookup.call)
|
|
340
|
+
return nil if notes.empty?
|
|
341
|
+
|
|
342
|
+
ref = @model_lookup.call&.ref
|
|
343
|
+
notes.group_by(&:scope).map do |scope, list|
|
|
344
|
+
heading = ref.to_s.empty? ? "Model notes (scope=#{scope}):" : "Model notes (for #{ref}, scope=#{scope}):"
|
|
345
|
+
[heading, *list.map { |note| "## #{note.name}\n#{note.body}" }].join("\n\n")
|
|
346
|
+
end.join("\n\n").tap { @section_notes = notes }
|
|
347
|
+
rescue StandardError
|
|
348
|
+
nil
|
|
349
|
+
end
|
|
350
|
+
|
|
298
351
|
# ── Memory helpers ─────────────────────────────────────────────────────────
|
|
299
352
|
|
|
300
353
|
# The session model's overlay keys, so the prompt's own memory bodies
|
|
@@ -308,9 +361,23 @@ module Samagotchi
|
|
|
308
361
|
{}
|
|
309
362
|
end
|
|
310
363
|
|
|
311
|
-
#
|
|
364
|
+
# { system: { tokens:, lines: }, project: { … } } of the index texts
|
|
365
|
+
# the prompt holds; nil when they can't be measured.
|
|
366
|
+
def measure_indexes(indexes)
|
|
367
|
+
chars_per_token = MemoryBundle::IndexSize.chars_per_token
|
|
368
|
+
%i[system project].to_h do |scope|
|
|
369
|
+
[scope, MemoryBundle::IndexSize.of_text(scope, indexes[scope], chars_per_token: chars_per_token).figures]
|
|
370
|
+
end
|
|
371
|
+
rescue StandardError
|
|
372
|
+
nil
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# The scope's index text without the muted memories' lines, nor the
|
|
376
|
+
# model notes' (their bodies are in their own section, or not for this
|
|
377
|
+
# model).
|
|
312
378
|
def read_memory_index(scope)
|
|
313
|
-
|
|
379
|
+
index = MutedMemories.filter_index(Tools::MemoryRead.call("", scope: scope), @muted_memory_names)
|
|
380
|
+
BundleNeeds.annotate_index(ModelNotes.filter_index(index, scope), scope)
|
|
314
381
|
end
|
|
315
382
|
|
|
316
383
|
# Merge the config.yml `memories:` baseline with the explicit `--memory`
|
|
@@ -473,7 +540,8 @@ module Samagotchi
|
|
|
473
540
|
"Model: this session runs on #{model.ref}#{where}.\n" \
|
|
474
541
|
"Asked which model you are, answer with this line, not from training: a fine-tuned model often knows only " \
|
|
475
542
|
"its base model's name, but this session runs what is named here; it changes only with /model. " \
|
|
476
|
-
"Guidance for this model only goes in
|
|
543
|
+
"Guidance for this model only goes in a model note: a memory named model_notes_<name> whose first line is " \
|
|
544
|
+
"`models: <glob on this model's id or key>`."
|
|
477
545
|
rescue StandardError
|
|
478
546
|
nil
|
|
479
547
|
end
|
|
@@ -36,14 +36,21 @@ module Samagotchi
|
|
|
36
36
|
# @param memories [Array<String>] --memory: a new session's worker
|
|
37
37
|
# preloads them (an existing session keeps its own list)
|
|
38
38
|
# @param muted_memories [Array<String>] --mute: hidden from a new session
|
|
39
|
+
# @param llm_context [Hash{Symbol => String}] --llm-context* as typed
|
|
40
|
+
# (LLMContextOverride.update): a new session starts with them; a
|
|
41
|
+
# resumed or attached one's worker gets a /llm-context, after the
|
|
42
|
+
# /model
|
|
39
43
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
40
44
|
# :failed when the --model switch didn't go through, or (input from a
|
|
41
45
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
42
46
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
43
|
-
memories: [], muted_memories: [])
|
|
47
|
+
memories: [], muted_memories: [], llm_context: {})
|
|
44
48
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
45
|
-
memories: memories, muted_memories: muted_memories)
|
|
46
|
-
|
|
49
|
+
memories: memories, muted_memories: muted_memories, llm_context: llm_context)
|
|
50
|
+
commands = []
|
|
51
|
+
commands << "/model #{model}" if model && (attach || resume)
|
|
52
|
+
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
53
|
+
first_command = commands.size > 1 ? commands : commands.first
|
|
47
54
|
surface = open_surface
|
|
48
55
|
attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
|
|
49
56
|
first_command: first_command, no_interrupt: no_interrupt,
|
|
@@ -104,7 +111,7 @@ module Samagotchi
|
|
|
104
111
|
# @return [BridgeClient] a client for the live Bridge of the session
|
|
105
112
|
# @raise [Error]
|
|
106
113
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
107
|
-
memories: [], muted_memories: [], err: $stderr)
|
|
114
|
+
memories: [], muted_memories: [], llm_context: {}, err: $stderr)
|
|
108
115
|
sd = state_dir || Session.default_state_dir
|
|
109
116
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
|
|
110
117
|
if attach
|
|
@@ -119,7 +126,8 @@ module Samagotchi
|
|
|
119
126
|
SessionManager.resume_session(resume, state_dir: sd)
|
|
120
127
|
else
|
|
121
128
|
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
122
|
-
memories: memories, muted_memories: muted_memories
|
|
129
|
+
memories: memories, muted_memories: muted_memories,
|
|
130
|
+
llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context))
|
|
123
131
|
end
|
|
124
132
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
125
133
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -82,8 +82,9 @@ module Samagotchi
|
|
|
82
82
|
# PlainSurface when the terminal can't show a live region)
|
|
83
83
|
# @param client_id [String] this UI's id in the events ("tui:<pid>")
|
|
84
84
|
# @param first_prompt [String, nil] sent once joined (`chi -p`)
|
|
85
|
-
# @param first_command [String, nil] run before the
|
|
86
|
-
# (`--model` on a resumed session: "/model X"
|
|
85
|
+
# @param first_command [String, Array<String>, nil] run before the
|
|
86
|
+
# first prompt, in order (`--model` on a resumed session: "/model X";
|
|
87
|
+
# --llm-context: "/llm-context strategy …"); if one doesn't go
|
|
87
88
|
# through, the launch stops
|
|
88
89
|
# @param no_interrupt [Boolean] post every turn with no_interrupt
|
|
89
90
|
# @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
|
|
@@ -127,7 +128,7 @@ module Samagotchi
|
|
|
127
128
|
@answered_ids = Set.new
|
|
128
129
|
@reader = nil
|
|
129
130
|
@first_prompt = first_prompt
|
|
130
|
-
@
|
|
131
|
+
@first_commands = Array(first_command)
|
|
131
132
|
@first_command_id = nil
|
|
132
133
|
@no_interrupt = no_interrupt
|
|
133
134
|
@no_default_input = !default_input
|
|
@@ -521,6 +522,9 @@ module Samagotchi
|
|
|
521
522
|
return
|
|
522
523
|
end
|
|
523
524
|
return @screen.commit(stale_worker("run commands")) if reply.status == 404
|
|
525
|
+
|
|
526
|
+
name = older_worker_command(reply, line)
|
|
527
|
+
return @screen.commit(stale_worker("run #{name}")) if name
|
|
524
528
|
return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
|
|
525
529
|
|
|
526
530
|
detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
|
|
@@ -827,6 +831,14 @@ module Samagotchi
|
|
|
827
831
|
# so it shows above what it got.
|
|
828
832
|
def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
|
|
829
833
|
|
|
834
|
+
# The name of this chi's own command +line+ when the worker answered it
|
|
835
|
+
# unknown_command (it runs an older chi without it), else nil.
|
|
836
|
+
def older_worker_command(reply, line)
|
|
837
|
+
return nil unless reply.status == 400 && reply.json&.fetch("error", nil) == "unknown_command"
|
|
838
|
+
|
|
839
|
+
SessionCommands.builtin_name(line)
|
|
840
|
+
end
|
|
841
|
+
|
|
830
842
|
def stale_worker(cant)
|
|
831
843
|
BridgeClient.stale_worker_message(@client.session_id, cant: cant)
|
|
832
844
|
end
|
|
@@ -980,18 +992,19 @@ module Samagotchi
|
|
|
980
992
|
@shown_enqueued << entry[:enqueued_id]
|
|
981
993
|
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
|
|
982
994
|
end
|
|
983
|
-
return send_first_command
|
|
995
|
+
return send_first_command unless @first_commands.empty?
|
|
984
996
|
|
|
985
997
|
send_first_prompt
|
|
986
998
|
nil
|
|
987
999
|
end
|
|
988
1000
|
|
|
989
|
-
# --model on a resumed session:
|
|
990
|
-
# prompt goes; its :command_ran decides
|
|
1001
|
+
# --model (or --llm-context) on a resumed session: run it in its
|
|
1002
|
+
# worker before the first prompt goes; its :command_ran decides
|
|
1003
|
+
# (#first_command_ran), and sends the next one.
|
|
991
1004
|
# @return [Symbol, nil] :failed when the worker didn't take it
|
|
992
1005
|
def send_first_command
|
|
993
|
-
line = @
|
|
994
|
-
@
|
|
1006
|
+
line = @first_commands.shift
|
|
1007
|
+
@first_command_line = line
|
|
995
1008
|
reply = @client.post_command(line: line, client_id: @client_id)
|
|
996
1009
|
if reply.status == 202
|
|
997
1010
|
@first_command_id = reply.json&.fetch("command_id", nil)
|
|
@@ -1000,28 +1013,35 @@ module Samagotchi
|
|
|
1000
1013
|
|
|
1001
1014
|
why =
|
|
1002
1015
|
if reply.status == 404 then stale_worker("run commands")
|
|
1016
|
+
elsif (name = older_worker_command(reply, line)) then stale_worker("run #{name}")
|
|
1003
1017
|
elsif too_late?(reply) then LATE
|
|
1004
1018
|
else "the worker answered #{reply.status}"
|
|
1005
1019
|
end
|
|
1006
|
-
@screen.commit("
|
|
1020
|
+
@screen.commit("#{first_command_failed}: #{why}")
|
|
1007
1021
|
:failed
|
|
1008
1022
|
rescue SystemCallError, IOError => e
|
|
1009
|
-
@screen.commit("
|
|
1023
|
+
@screen.commit("#{first_command_failed}: #{worker_down(e)}")
|
|
1010
1024
|
:failed
|
|
1011
1025
|
end
|
|
1012
1026
|
|
|
1013
|
-
# @return [Symbol, nil] :failed unless the
|
|
1027
|
+
# @return [Symbol, nil] :failed unless the command went through
|
|
1014
1028
|
def first_command_ran(event)
|
|
1015
1029
|
@first_command_id = nil
|
|
1016
1030
|
if event[:status] == "ok"
|
|
1031
|
+
return send_first_command unless @first_commands.empty?
|
|
1032
|
+
|
|
1017
1033
|
send_first_prompt
|
|
1018
1034
|
return nil
|
|
1019
1035
|
end
|
|
1020
1036
|
|
|
1021
|
-
@screen.commit("
|
|
1037
|
+
@screen.commit("#{first_command_failed}: #{event[:output]}")
|
|
1022
1038
|
:failed
|
|
1023
1039
|
end
|
|
1024
1040
|
|
|
1041
|
+
def first_command_failed
|
|
1042
|
+
@first_command_line.to_s.start_with?("/model") ? "could not switch to the --model" : "could not set the --llm-context"
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1025
1045
|
# After the join, so it lands below what the session already had. Our
|
|
1026
1046
|
# own prompts are never echoed from events (a typed one stays on screen),
|
|
1027
1047
|
# so it is shown here as if typed.
|
|
@@ -94,6 +94,8 @@ module Samagotchi
|
|
|
94
94
|
# that couldn't be used).
|
|
95
95
|
@view.finish_thinking_spinner
|
|
96
96
|
@view.print_line(@view.turn_failed_line(event[:summary] || event[:message], event[:duration_ms]))
|
|
97
|
+
# Its work so far stayed (the Engine's kept_steps), as after a cancel.
|
|
98
|
+
@view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) if event[:kept_steps] && !@turn_continues
|
|
97
99
|
when :guardrail_warning
|
|
98
100
|
@view.print_line(self.class.load_warning_line(event))
|
|
99
101
|
when :hook_notice
|
|
@@ -5,6 +5,8 @@ require_relative "../served_model"
|
|
|
5
5
|
require_relative "../image_store"
|
|
6
6
|
require_relative "../turn_note"
|
|
7
7
|
require_relative "../steer"
|
|
8
|
+
require_relative "../memory_bundle/index_size"
|
|
9
|
+
require_relative "../prompt_note"
|
|
8
10
|
|
|
9
11
|
module Samagotchi
|
|
10
12
|
class TerminalUI
|
|
@@ -314,6 +316,25 @@ module Samagotchi
|
|
|
314
316
|
"#{"~" if source.to_s == "estimate"}#{number} tok/s"
|
|
315
317
|
end
|
|
316
318
|
|
|
319
|
+
# "~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)":
|
|
320
|
+
# the memory indexes the session's prompt holds (the snapshot's
|
|
321
|
+
# memory_index, symbol or string keys); "" without one. As ctx.js
|
|
322
|
+
# memoryIndexText (spec/shared/labels_matrix.json).
|
|
323
|
+
def memory_index_text(block)
|
|
324
|
+
return "" unless block.is_a?(Hash)
|
|
325
|
+
|
|
326
|
+
scopes = %w[system project].filter_map do |scope|
|
|
327
|
+
figures = block[scope] || block[scope.to_sym]
|
|
328
|
+
tokens = figures.is_a?(Hash) ? (figures["tokens"] || figures[:tokens]) : nil
|
|
329
|
+
[scope, tokens.to_i] if tokens.is_a?(Numeric)
|
|
330
|
+
end
|
|
331
|
+
return "" if scopes.empty?
|
|
332
|
+
|
|
333
|
+
count = ->(tokens) { MemoryBundle::IndexSize.count_text(tokens) }
|
|
334
|
+
"~#{count.call(scopes.sum(&:last))} tokens in this session's prompt " \
|
|
335
|
+
"(#{scopes.map { |scope, tokens| "#{scope} #{count.call(tokens)}" }.join(", ")})"
|
|
336
|
+
end
|
|
337
|
+
|
|
317
338
|
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
|
|
318
339
|
# none. As ctx.js costText.
|
|
319
340
|
def cost_text(cost)
|
|
@@ -463,6 +484,13 @@ module Samagotchi
|
|
|
463
484
|
end
|
|
464
485
|
lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
|
|
465
486
|
lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
|
|
487
|
+
memory_index = memory_index_text(snapshot[:memory_index])
|
|
488
|
+
lines << "memory index: #{memory_index}" unless memory_index.empty?
|
|
489
|
+
# The model notes the session's prompt carried (Engine#prompt_notes).
|
|
490
|
+
notes = PromptNote.text(snapshot[:prompt_notes])
|
|
491
|
+
lines << "model notes: #{notes}" unless notes.empty?
|
|
492
|
+
llm_context = llm_context_stats_text(snapshot[:llm_context])
|
|
493
|
+
lines << "llm context: #{llm_context}" if llm_context
|
|
466
494
|
if snapshot[:served_model]
|
|
467
495
|
asked = snapshot[:served_model_for]
|
|
468
496
|
note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
|
|
@@ -471,6 +499,18 @@ module Samagotchi
|
|
|
471
499
|
lines.join("\n")
|
|
472
500
|
end
|
|
473
501
|
|
|
502
|
+
# "stale,forget (the session); apply turn_end (models: x); budget
|
|
503
|
+
# 64000 (the session)" for /stats (Explained#summary; symbol or
|
|
504
|
+
# string keys, the attached TUI's come as JSON); nil without one.
|
|
505
|
+
def llm_context_stats_text(summary)
|
|
506
|
+
return nil unless summary.is_a?(Hash)
|
|
507
|
+
|
|
508
|
+
get = ->(key) { summary[key] || summary[key.to_s] }
|
|
509
|
+
budget = get.call(:budget_tokens) ? "#{get.call(:budget_tokens)} tokens" : "off"
|
|
510
|
+
"#{get.call(:strategy)} (#{get.call(:strategy_where)}); apply #{get.call(:apply)} (#{get.call(:apply_where)}); " \
|
|
511
|
+
"budget #{budget} (#{get.call(:budget_where)})"
|
|
512
|
+
end
|
|
513
|
+
|
|
474
514
|
# ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
|
|
475
515
|
# 212" for the tokens line; the counts a server didn't report are left
|
|
476
516
|
# out (re-prefilled: SessionMetrics' reprefill_sum).
|
|
@@ -85,10 +85,13 @@ module Samagotchi
|
|
|
85
85
|
|
|
86
86
|
# @param scratch [Boolean] `chi scratch`: a new session that is deleted
|
|
87
87
|
# however the REPL ends, saves no memories and writes no recap
|
|
88
|
+
# @param llm_context [Hash{Symbol => String}] --llm-context* as typed
|
|
89
|
+
# (LLMContextOverride.update): set on the new or resumed session
|
|
88
90
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
89
|
-
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
|
|
91
|
+
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {})
|
|
90
92
|
@scratch = scratch
|
|
91
93
|
@prompt = prompt
|
|
94
|
+
@llm_context_words = llm_context
|
|
92
95
|
@default_model_name = ModelProfile.required_model_name(nil)
|
|
93
96
|
# As typed: the Engine applies an alias where it resolves the model.
|
|
94
97
|
flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
|
|
@@ -238,6 +241,12 @@ module Samagotchi
|
|
|
238
241
|
muted_memory_names: @muted_memory_names,
|
|
239
242
|
scratch: @scratch
|
|
240
243
|
))
|
|
244
|
+
# --llm-context*: the session's own values, over a resumed one's.
|
|
245
|
+
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
246
|
+
unless @llm_context_words.empty?
|
|
247
|
+
session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words)
|
|
248
|
+
session.save if @resume_session
|
|
249
|
+
end
|
|
241
250
|
claim_session!(session.id) unless @owner_lock
|
|
242
251
|
keep_cards(session)
|
|
243
252
|
# Saved at once, so a process killed before its first turn leaves a
|
|
@@ -759,20 +768,21 @@ module Samagotchi
|
|
|
759
768
|
rescue LLM::ProviderError, ImageStore::Error => e
|
|
760
769
|
# Engine closed the turn (:turn_failed), which the renderer showed.
|
|
761
770
|
# An @path image that can't be used fails the turn the same way.
|
|
762
|
-
summary = e.respond_to?(:summary) ? e.summary : e.message
|
|
763
771
|
# The model reads why on its next turn. An image that couldn't be
|
|
764
772
|
# used, or that the host refused, never reached it: no note for that
|
|
765
773
|
# (a first turn refused that way leaves the session empty, as before).
|
|
766
|
-
note =
|
|
767
|
-
|
|
774
|
+
note = e.is_a?(LLM::ProviderError) && !e.is_a?(LLM::VisionUnsupported)
|
|
775
|
+
# A turn that got somewhere stays (the renderer said so, and that
|
|
776
|
+
# !rollback erases it), as the Engine saved it: no prompt goes back.
|
|
777
|
+
kept = @turn_flow.prompt_turn_failed(error: e, note: note) == :kept
|
|
768
778
|
# The Engine saved the failed turn; the file follows the rollback.
|
|
769
|
-
save_session(session) if note
|
|
779
|
+
save_session(session) if note || kept
|
|
770
780
|
if piped_input?
|
|
771
781
|
# Nobody can edit a restored prompt, and Reline would hand it back
|
|
772
782
|
# as the next line: the turn would run again and again. It failed;
|
|
773
783
|
# the exit status says so.
|
|
774
784
|
@piped_turn_failed = true
|
|
775
|
-
|
|
785
|
+
elsif !kept
|
|
776
786
|
@surface.commit(turn_end_hint(restore_prompt_for_retry(input)))
|
|
777
787
|
end
|
|
778
788
|
return
|
|
@@ -8,6 +8,7 @@ require_relative "log"
|
|
|
8
8
|
require_relative "edit_preview"
|
|
9
9
|
require_relative "tools/tool_path"
|
|
10
10
|
require_relative "memory_bundle/index_sync"
|
|
11
|
+
require_relative "memory_bundle/index_size"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# The single per-call path both loops use: the tool_call_started and
|
|
@@ -71,9 +72,12 @@ module Samagotchi
|
|
|
71
72
|
waited_ms = ((@clock.call - asked_at) * 1000).round
|
|
72
73
|
end
|
|
73
74
|
before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
|
|
75
|
+
index_before = before.equal?(NOT_AN_EDIT) ? nil : memory_index_size(call)
|
|
74
76
|
result = verdict.deny? ? denied(call, verdict) : dispatch(call)
|
|
75
77
|
diff = file_change(call, before)
|
|
76
|
-
refresh_memory_index(call)
|
|
78
|
+
if diff && refresh_memory_index(call)
|
|
79
|
+
result = with_index_note(result, index_before, call)
|
|
80
|
+
end
|
|
77
81
|
result = approved(result, verdict) if verdict.allow? && verdict.decided_by
|
|
78
82
|
result, images = attach_images(call, result) if result[:images]
|
|
79
83
|
|
|
@@ -154,6 +158,27 @@ module Samagotchi
|
|
|
154
158
|
MemoryBundle::IndexSync.refresh(Tools::ToolPath.normalize(call[:path]))
|
|
155
159
|
end
|
|
156
160
|
|
|
161
|
+
# The index size of the memory scope an edit/write's path is in
|
|
162
|
+
# (MemoryBundle::IndexSize), nil for any other path, or when
|
|
163
|
+
# memory.index_warn_tokens is off: other writes cost nothing.
|
|
164
|
+
def memory_index_size(call)
|
|
165
|
+
scope = MemoryBundle::IndexSync.memory_scope(Tools::ToolPath.normalize(call[:path]))
|
|
166
|
+
return nil unless scope && MemoryBundle::IndexSize.warn_limit.positive?
|
|
167
|
+
|
|
168
|
+
MemoryBundle::IndexSize.measure(scope)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# +result+ with the note memory_write gives when the refreshed index
|
|
174
|
+
# line took the scope's index over memory.index_warn_tokens.
|
|
175
|
+
def with_index_note(result, before, call)
|
|
176
|
+
return result unless before
|
|
177
|
+
|
|
178
|
+
note = MemoryBundle::IndexSize.crossing_note(before, memory_index_size(call))
|
|
179
|
+
note ? result.merge(output: "#{result[:output]}\n\n#{note}") : result
|
|
180
|
+
end
|
|
181
|
+
|
|
157
182
|
# A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
|
|
158
183
|
# binary file). They become "?" here, before the output reaches the
|
|
159
184
|
# conversation, the events and the saved session: JSON.generate raises
|
|
@@ -5,11 +5,14 @@ require "digest"
|
|
|
5
5
|
require "date"
|
|
6
6
|
require_relative "../atomic_file"
|
|
7
7
|
require_relative "../memory_paths"
|
|
8
|
+
require_relative "../model_match"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Tools
|
|
11
12
|
MEMORY_INDEX = "index"
|
|
12
13
|
VALID_SCOPES = %w[project system].freeze
|
|
14
|
+
# Model notes (ModelNotes): `model_notes_<name>`, no dot in the name.
|
|
15
|
+
MODEL_NOTES_PREFIX = "model_notes_"
|
|
13
16
|
|
|
14
17
|
# Reads a memory entry from scoped memory directories.
|
|
15
18
|
# When scope is omitted, reads from project first and falls back to system.
|
|
@@ -111,8 +114,11 @@ module Samagotchi
|
|
|
111
114
|
raise ArgumentError, "invalid scope '#{scope}', expected one of: #{VALID_SCOPES.join(", ")}"
|
|
112
115
|
end
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
# +scope+'s index.md, or a listing of its files when it has none: what
|
|
118
|
+
# a session's prompt carries for the scope (before its filters).
|
|
119
|
+
# @param env [Hash] the XDG env the memories dir resolves from
|
|
120
|
+
def self.scoped_index(scope, env: ENV)
|
|
121
|
+
dir = memories_dir(scope, env: env)
|
|
116
122
|
index_path = File.join(dir, "#{MEMORY_INDEX}.md")
|
|
117
123
|
return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
|
|
118
124
|
|
|
@@ -170,6 +176,14 @@ module Samagotchi
|
|
|
170
176
|
|
|
171
177
|
return remove(entry_name, scope)
|
|
172
178
|
end
|
|
179
|
+
if entry_name.include?(",")
|
|
180
|
+
return "Error: invalid memory name '#{entry_name}': no commas (memory_read reads a comma list of names); " \
|
|
181
|
+
"use '#{entry_name.tr(",", "_")}'"
|
|
182
|
+
end
|
|
183
|
+
return dotted_model_note_error(entry_name) if dotted_model_note?(entry_name)
|
|
184
|
+
if (error = model_note_body_error(entry_name, body, current_model_only))
|
|
185
|
+
return error
|
|
186
|
+
end
|
|
173
187
|
|
|
174
188
|
description = normalize_description(description)
|
|
175
189
|
if description && description.length > DESCRIPTION_LIMIT
|
|
@@ -210,6 +224,9 @@ module Samagotchi
|
|
|
210
224
|
end
|
|
211
225
|
|
|
212
226
|
file_path = File.join(dir, "#{entry_name}.md")
|
|
227
|
+
# The index's size before the write, for the note on crossing
|
|
228
|
+
# memory.index_warn_tokens (not for the verbatim index write).
|
|
229
|
+
before = index_size(resolved_scope) unless entry_name == MEMORY_INDEX
|
|
213
230
|
if entry_name == MEMORY_INDEX
|
|
214
231
|
Samagotchi::MemoryBundle::IndexUpdater.locked_write(dir) { body }
|
|
215
232
|
else
|
|
@@ -222,6 +239,7 @@ module Samagotchi
|
|
|
222
239
|
# trigger upsert logic.
|
|
223
240
|
if !(entry_name == MEMORY_INDEX) && manage_index(resolved_scope, entry_name, bytes, description)
|
|
224
241
|
message += " Index line refreshed automatically."
|
|
242
|
+
message += index_crossing_note(before, resolved_scope)
|
|
225
243
|
end
|
|
226
244
|
|
|
227
245
|
message
|
|
@@ -229,6 +247,34 @@ module Samagotchi
|
|
|
229
247
|
"Error: #{e.message}"
|
|
230
248
|
end
|
|
231
249
|
|
|
250
|
+
# A model note's name with a dot would read as a model overlay
|
|
251
|
+
# (`<name>.<key>.md`) next to a note of the stem's name.
|
|
252
|
+
def self.dotted_model_note?(name) = name.start_with?(MODEL_NOTES_PREFIX) && name.include?(".")
|
|
253
|
+
|
|
254
|
+
def self.dotted_model_note_error(name)
|
|
255
|
+
"Error: invalid model note name '#{name}': no dot after #{MODEL_NOTES_PREFIX} (a dotted name reads as a " \
|
|
256
|
+
"model overlay); use '#{undotted(name)}'"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# +name+ with its dots made dashes, a trailing ".md" dropped first
|
|
260
|
+
# (`model_notes_x.md` → `model_notes_x`).
|
|
261
|
+
def self.undotted(name) = name.delete_suffix(".md").tr(".", "-")
|
|
262
|
+
|
|
263
|
+
# A model note's content must start with its models: line, or the note
|
|
264
|
+
# would never load (and its index line would hide it from no one). Its
|
|
265
|
+
# model overlay (current_model_only) has none.
|
|
266
|
+
def self.model_note_body_error(name, body, current_model_only)
|
|
267
|
+
return nil unless name.start_with?(MODEL_NOTES_PREFIX) && !body.empty? && !current_model_only
|
|
268
|
+
return nil if ModelMatch.models_line(body)
|
|
269
|
+
|
|
270
|
+
first = body.each_line.first.to_s.strip
|
|
271
|
+
first = "#{first[0, 60]}…" if first.length > 60
|
|
272
|
+
"Error: model note '#{name}' needs a first line saying which models it is for, `models: <glob>|small|…` " \
|
|
273
|
+
"(e.g. `models: deepseek-*|small`: globs on the model id or key, small per guardrails.small_models), " \
|
|
274
|
+
"then the note; the first line is #{first.empty? ? "empty" : "`#{first}`"}. A memory that isn't a model " \
|
|
275
|
+
"note needs a name without the #{MODEL_NOTES_PREFIX} prefix."
|
|
276
|
+
end
|
|
277
|
+
|
|
232
278
|
# A description as the index line takes it: whitespace (newlines too)
|
|
233
279
|
# collapsed to single spaces; nil when blank, which keeps the stored one.
|
|
234
280
|
def self.normalize_description(description)
|
|
@@ -251,8 +297,29 @@ module Samagotchi
|
|
|
251
297
|
file_path = File.join(MemoryRead.memories_dir(resolved_scope), "#{entry_name}.md")
|
|
252
298
|
return "Error: no memory '#{entry_name}' in #{resolved_scope} scope; pass content to create it" unless File.file?(file_path)
|
|
253
299
|
|
|
300
|
+
# Measured around the call, whatever manage_index returns.
|
|
301
|
+
before = index_size(resolved_scope)
|
|
254
302
|
manage_index(resolved_scope, entry_name, File.size(file_path), description)
|
|
255
|
-
"Memory '#{entry_name}' description updated in #{resolved_scope} scope (file unchanged)."
|
|
303
|
+
"Memory '#{entry_name}' description updated in #{resolved_scope} scope (file unchanged)." \
|
|
304
|
+
"#{index_crossing_note(before, resolved_scope)}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# +scope+'s index size (MemoryBundle::IndexSize), or nil when
|
|
308
|
+
# memory.index_warn_tokens is off or the index can't be read.
|
|
309
|
+
def self.index_size(scope)
|
|
310
|
+
require_relative "../memory_bundle/index_size"
|
|
311
|
+
size = Samagotchi::MemoryBundle::IndexSize
|
|
312
|
+
size.measure(scope) if size.warn_limit.positive?
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# "\n\nNote: the <scope> memory index is now ~N tokens …" when the
|
|
316
|
+
# write took +scope+'s index from at or under memory.index_warn_tokens
|
|
317
|
+
# to over it (+before+: its size before the write), else "".
|
|
318
|
+
def self.index_crossing_note(before, scope)
|
|
319
|
+
return "" unless before
|
|
320
|
+
|
|
321
|
+
note = Samagotchi::MemoryBundle::IndexSize.crossing_note(before, index_size(scope))
|
|
322
|
+
note ? "\n\n#{note}" : ""
|
|
256
323
|
end
|
|
257
324
|
|
|
258
325
|
# remove: true. Moves <name>.md and its model overlays into one
|