samagotchi 0.155.0 → 0.157.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/bin/chi +2 -1
- data/docs/architecture.md +22 -13
- data/docs/bundles.md +3 -1
- data/docs/cli.md +9 -8
- data/docs/configuration.md +36 -4
- data/docs/guardrails.md +9 -5
- data/docs/hooks.md +11 -5
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/attached-context.md +3 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +83 -6
- data/docs/internals/bundles.md +28 -18
- data/docs/internals/context-telemetry.md +4 -3
- data/docs/internals/engine.md +641 -937
- data/docs/internals/llm-context-forget.md +8 -3
- data/docs/internals/objectives.md +2 -2
- data/docs/internals/participants.md +3 -2
- data/docs/internals/prompt-caching.md +4 -1
- data/docs/internals/remote-sessions.md +8 -3
- data/docs/internals/web-frontend.md +3 -2
- data/docs/internals/working-set.md +2 -2
- data/docs/memory.md +13 -2
- data/docs/plugins.md +35 -2
- data/docs/sessions.md +11 -11
- data/docs/sub-agent.md +17 -2
- data/docs/system-one.md +3 -2
- data/docs/testing.md +53 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +4 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +3 -2
- data/lib/samagotchi/bridge/card_places.rb +10 -4
- data/lib/samagotchi/bridge/card_store.rb +3 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
- data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
- data/lib/samagotchi/bridge.rb +31 -6
- data/lib/samagotchi/broadcast/recipients.rb +8 -8
- data/lib/samagotchi/broadcast/triage_model.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +3 -3
- data/lib/samagotchi/bundle_command/check.rb +18 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +5 -2
- data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
- data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
- data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_move.rb +13 -57
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +15 -21
- data/lib/samagotchi/cli/parent_wait.rb +9 -23
- data/lib/samagotchi/config.rb +20 -112
- data/lib/samagotchi/context_command.rb +1 -2
- data/lib/samagotchi/context_sources.rb +5 -4
- data/lib/samagotchi/context_window.rb +9 -12
- data/lib/samagotchi/desktop/macos.rb +1 -1
- data/lib/samagotchi/effective_model.rb +57 -22
- data/lib/samagotchi/engine/context_judge.rb +19 -11
- data/lib/samagotchi/engine/messages.rb +1 -1
- data/lib/samagotchi/engine/models.rb +5 -2
- data/lib/samagotchi/engine/objectives.rb +29 -11
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/recap.rb +8 -4
- data/lib/samagotchi/engine/side_requests.rb +21 -11
- data/lib/samagotchi/engine/toolkit.rb +10 -9
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +7 -7
- data/lib/samagotchi/engine/verify.rb +1 -1
- data/lib/samagotchi/engine.rb +43 -12
- data/lib/samagotchi/events.rb +3 -2
- data/lib/samagotchi/extension_load.rb +6 -3
- data/lib/samagotchi/guardrails/targets.rb +24 -9
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/host_address.rb +52 -0
- data/lib/samagotchi/host_config.rb +72 -1
- data/lib/samagotchi/host_model.rb +2 -6
- data/lib/samagotchi/host_registry.rb +58 -46
- data/lib/samagotchi/kernel_loop.rb +53 -124
- data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
- data/lib/samagotchi/llm/chat_loop.rb +40 -21
- data/lib/samagotchi/llm/native_backend.rb +1 -1
- data/lib/samagotchi/llm_context_apply.rb +54 -1
- data/lib/samagotchi/llm_context_notice.rb +1 -1
- data/lib/samagotchi/llm_context_strategy.rb +29 -67
- data/lib/samagotchi/llm_context_view.rb +9 -0
- data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
- data/lib/samagotchi/memory_bundle/builder.rb +2 -1
- data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
- data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
- data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
- data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
- data/lib/samagotchi/memory_bundle/installer.rb +185 -169
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +69 -65
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
- data/lib/samagotchi/memory_bundle/profile.rb +5 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
- data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
- data/lib/samagotchi/memory_bundle/status.rb +14 -20
- data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
- data/lib/samagotchi/memory_bundle/trash.rb +23 -8
- data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
- data/lib/samagotchi/memory_usage.rb +1 -1
- data/lib/samagotchi/model_lookup.rb +75 -0
- data/lib/samagotchi/model_price.rb +4 -10
- data/lib/samagotchi/model_profile.rb +7 -6
- data/lib/samagotchi/model_settings.rb +37 -2
- data/lib/samagotchi/models_command.rb +1 -1
- data/lib/samagotchi/note_command.rb +2 -3
- data/lib/samagotchi/objectives_audit.rb +4 -3
- data/lib/samagotchi/participant_mode.rb +2 -1
- data/lib/samagotchi/participants.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +41 -6
- data/lib/samagotchi/plugin/context.rb +1 -1
- data/lib/samagotchi/plugin/loader.rb +33 -10
- data/lib/samagotchi/plugin/sessions.rb +3 -6
- data/lib/samagotchi/plugin/tool_spec.rb +5 -3
- data/lib/samagotchi/project_env.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -20
- data/lib/samagotchi/recap_place.rb +27 -0
- data/lib/samagotchi/reminder_carry.rb +1 -1
- data/lib/samagotchi/reply_wait/liveness.rb +38 -0
- data/lib/samagotchi/reply_wait/poll.rb +44 -15
- data/lib/samagotchi/reply_wait/target.rb +113 -0
- data/lib/samagotchi/reply_wait.rb +22 -2
- data/lib/samagotchi/reprefill_meter.rb +45 -0
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/sampling_settings.rb +9 -16
- data/lib/samagotchi/self_report.rb +36 -37
- data/lib/samagotchi/send_command.rb +17 -23
- data/lib/samagotchi/session.rb +370 -445
- data/lib/samagotchi/session_access/local.rb +12 -11
- data/lib/samagotchi/session_access/remote.rb +22 -6
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +7 -6
- data/lib/samagotchi/session_continue.rb +6 -5
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_field.rb +133 -0
- data/lib/samagotchi/session_inbox.rb +49 -1
- data/lib/samagotchi/session_manager/delivery.rb +254 -0
- data/lib/samagotchi/session_manager/emptiness.rb +139 -0
- data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
- data/lib/samagotchi/session_manager/listing.rb +118 -0
- data/lib/samagotchi/session_manager/removal.rb +190 -0
- data/lib/samagotchi/session_manager/spawn.rb +242 -0
- data/lib/samagotchi/session_manager/worker_command.rb +157 -0
- data/lib/samagotchi/session_manager.rb +28 -1541
- data/lib/samagotchi/session_metrics.rb +8 -5
- data/lib/samagotchi/session_paths.rb +57 -0
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/session_row.rb +200 -0
- data/lib/samagotchi/session_setup.rb +15 -12
- data/lib/samagotchi/sessions_command.rb +11 -129
- data/lib/samagotchi/share_command.rb +1 -1
- data/lib/samagotchi/starting_marker.rb +65 -0
- data/lib/samagotchi/steer_cut_after.rb +7 -14
- data/lib/samagotchi/system_one/recorder.rb +49 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
- data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
- data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +5 -7
- data/lib/samagotchi/testing/install.rb +2 -1
- data/lib/samagotchi/testing/sessions.rb +1 -1
- data/lib/samagotchi/thinking.rb +16 -21
- data/lib/samagotchi/token_usage.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +10 -4
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tool_output_cut.rb +17 -0
- data/lib/samagotchi/tool_response.rb +5 -3
- data/lib/samagotchi/tool_runner.rb +25 -5
- data/lib/samagotchi/toolkit_command.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +55 -45
- data/lib/samagotchi/tools/delegate_relay.rb +5 -5
- data/lib/samagotchi/tools/delegate_result.rb +5 -5
- data/lib/samagotchi/tools/delegate_wait.rb +41 -17
- data/lib/samagotchi/tools/index_hint.rb +14 -0
- data/lib/samagotchi/tools/list_sessions.rb +7 -7
- data/lib/samagotchi/tools/registry.rb +28 -11
- data/lib/samagotchi/tools/send_note.rb +4 -6
- data/lib/samagotchi/tools/tool_index.rb +52 -51
- data/lib/samagotchi/tools/tool_surface.rb +22 -8
- data/lib/samagotchi/turn_context.rb +2 -1
- data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
- data/lib/samagotchi/turn_stages/objectives.rb +56 -52
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
- data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
- data/lib/samagotchi/turn_stages/warmup.rb +14 -15
- data/lib/samagotchi/update_command.rb +11 -7
- data/lib/samagotchi/verify_fresh.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +11 -14
- data/lib/samagotchi/web/app.rb +45 -28
- data/lib/samagotchi/web/message_display.rb +57 -7
- data/lib/samagotchi/web/participant_access.rb +3 -3
- data/lib/samagotchi/web/public/activity.js +16 -7
- data/lib/samagotchi/web/public/app.js +29 -8
- data/lib/samagotchi/web/public/bubbles.js +8 -3
- data/lib/samagotchi/web/public/cards_place.js +15 -0
- data/lib/samagotchi/web/public/commands.js +5 -1
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/history_shape.js +220 -0
- data/lib/samagotchi/web/public/recap_stamp.js +17 -0
- data/lib/samagotchi/web/public/reload_check.js +71 -0
- data/lib/samagotchi/web/public/stage_model.js +7 -5
- data/lib/samagotchi/web/public/stage_view.js +7 -3
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +43 -2
- data/lib/samagotchi/web/public/turn_block.js +215 -30
- data/lib/samagotchi/web/public/turn_drawing.js +43 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -1
- data/lib/samagotchi/web/public/turn_pieces.js +29 -0
- data/lib/samagotchi/web/reload_check_route.rb +95 -0
- data/lib/samagotchi/web/server.rb +4 -2
- data/lib/samagotchi/web/session_access_routes.rb +6 -6
- data/lib/samagotchi/web/session_control.rb +4 -4
- data/lib/samagotchi/web/session_create.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +9 -17
- data/lib/samagotchi/web/session_show.rb +67 -5
- data/lib/samagotchi/web/session_summary.rb +39 -28
- data/lib/samagotchi/worker.rb +83 -32
- data/lib/samagotchi/worker_boot.rb +184 -0
- data/lib/samagotchi/worker_link.rb +3 -0
- data/lib/samagotchi/worker_signals.rb +1 -1
- data/lib/samagotchi/worker_task_wakes.rb +2 -2
- metadata +32 -4
- data/lib/samagotchi/send_command/wait_target.rb +0 -85
data/lib/samagotchi/session.rb
CHANGED
|
@@ -10,13 +10,18 @@ require_relative "interrupted_turn"
|
|
|
10
10
|
require_relative "last_turn"
|
|
11
11
|
require_relative "pending_question"
|
|
12
12
|
require_relative "llm_context_override"
|
|
13
|
+
require_relative "reasoning_history"
|
|
14
|
+
require_relative "thinking"
|
|
15
|
+
require_relative "toolkit"
|
|
16
|
+
require_relative "session_config"
|
|
13
17
|
require_relative "session_setup"
|
|
14
18
|
require_relative "prompt_note"
|
|
15
19
|
require_relative "working_set_pin"
|
|
16
20
|
require_relative "session_review"
|
|
21
|
+
require_relative "session_field"
|
|
22
|
+
require_relative "session_paths"
|
|
17
23
|
require_relative "version"
|
|
18
24
|
|
|
19
|
-
require_relative "paths"
|
|
20
25
|
require_relative "project_scope"
|
|
21
26
|
require_relative "guardrails/parent_levels"
|
|
22
27
|
|
|
@@ -29,9 +34,11 @@ module Samagotchi
|
|
|
29
34
|
autoload :ArchiveStore, File.expand_path("archive_store", __dir__)
|
|
30
35
|
|
|
31
36
|
class Session
|
|
37
|
+
# The paths and the id rule (SessionPaths): Session.session_dir,
|
|
38
|
+
# .session_file, .default_state_dir, .valid_id?, .check_id!.
|
|
39
|
+
extend SessionPaths
|
|
40
|
+
|
|
32
41
|
METADATA_VERSION = 3
|
|
33
|
-
STATE_SUBDIR = File.join("samagotchi", "sessions")
|
|
34
|
-
FILE_EXT = ".json"
|
|
35
42
|
|
|
36
43
|
STATUS_IDLE = "idle"
|
|
37
44
|
STATUS_RUNNING = "running"
|
|
@@ -65,195 +72,309 @@ module Samagotchi
|
|
|
65
72
|
SORT_KEYS = %w[created_at updated_at].freeze
|
|
66
73
|
SORT_ORDERS = %w[asc desc].freeze
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
# every list, card and tab shows it in place of #first_preview (see
|
|
84
|
-
# #display_title). A continue's new link inherits it.
|
|
85
|
-
attr_accessor :title
|
|
86
|
-
# The files chi --append-system-prompt put into the session's system
|
|
87
|
-
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
88
|
-
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
89
|
-
# (SessionSetup).
|
|
90
|
-
attr_reader :appended_prompt_files
|
|
91
|
-
|
|
92
|
-
def appended_prompt_files=(paths)
|
|
93
|
-
@appended_prompt_files = Array(paths).map(&:to_s).reject(&:empty?).uniq
|
|
75
|
+
# A field a session file must have (KeyError without it).
|
|
76
|
+
REQUIRED = SessionField::REQUIRED
|
|
77
|
+
|
|
78
|
+
# How the plain rows read and write their values.
|
|
79
|
+
BOOLEAN = ->(value) { !!value }
|
|
80
|
+
NAMES = ->(names) { name_list(names) }
|
|
81
|
+
LIST = ->(value) { Array(value) }
|
|
82
|
+
ID = ->(value) { value&.to_s }
|
|
83
|
+
MESSAGES = ->(messages) { SessionField.scrub_utf8(messages.map { |msg| SessionField.stringify_keys(msg) }) }
|
|
84
|
+
VERDICTS = lambda do |records|
|
|
85
|
+
records&.map do |record|
|
|
86
|
+
SessionField.scrub_utf8(record.to_h do |key, entry|
|
|
87
|
+
[key.to_s, entry.is_a?(Hash) ? SessionField.stringify_keys(entry) : entry]
|
|
88
|
+
end)
|
|
89
|
+
end
|
|
94
90
|
end
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
# turn, /model) and saved, so what a session ran with can be read later.
|
|
99
|
-
attr_reader :prompt_notes
|
|
100
|
-
|
|
101
|
-
# @param notes [Array<PromptNote, Hash>] a file's hashes are read
|
|
102
|
-
# (PromptNote.list)
|
|
103
|
-
def prompt_notes=(notes)
|
|
104
|
-
@prompt_notes = PromptNote.list(notes)
|
|
91
|
+
TOOLKIT_ORIGIN = lambda do |value|
|
|
92
|
+
origin = value.to_s
|
|
93
|
+
TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
|
|
105
94
|
end
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
attr_writer :project_root
|
|
115
|
-
# The session that delegated this one (the `delegate` tool) or that a
|
|
116
|
-
# plugin forked it from (ctx.sessions.fork), else nil.
|
|
117
|
-
# Set before the spawn and kept on respawns, like preloaded_memory_names.
|
|
118
|
-
attr_accessor :parent_id
|
|
119
|
-
# Started by the `delegate` tool (a fork has a parent_id too, but isn't
|
|
120
|
-
# one): see #delegate?.
|
|
121
|
-
attr_writer :delegate
|
|
122
|
-
# The session this one continues (SessionManager.continue_session): the
|
|
123
|
-
# previous link of a chain, "the next run of the same routine", else
|
|
124
|
-
# nil. Not a parent: archiving one link leaves the next alone. The chain
|
|
125
|
-
# itself is derived (SessionChain), never stored.
|
|
126
|
-
attr_accessor :continues
|
|
127
|
-
# A `chi scratch` session: deleted when its terminal leaves (its worker
|
|
128
|
-
# deletes it too once its last client is gone), and by the next sweep
|
|
129
|
-
# (or `chi sessions clean`) when both died first.
|
|
130
|
-
attr_accessor :scratch
|
|
131
|
-
# The question this session waits on (PendingQuestion), saved as
|
|
132
|
-
# session.json's "pending_question"; nil with none. A Hash (a saved
|
|
133
|
-
# record, string or symbol keys) is read with PendingQuestion.from_file.
|
|
134
|
-
attr_reader :pending_question
|
|
95
|
+
PARENT_ANSWERS = lambda do |value|
|
|
96
|
+
case (level = Guardrails::ParentPolicy.parse_level(value))
|
|
97
|
+
when :unset then nil
|
|
98
|
+
when :invalid then value.to_s.strip
|
|
99
|
+
else level
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :VERDICTS, :TOOLKIT_ORIGIN, :PARENT_ANSWERS
|
|
135
103
|
|
|
136
|
-
|
|
137
|
-
|
|
104
|
+
# The fields of <id>.json, in the order #save writes them: key => its
|
|
105
|
+
# SessionField (the value for a file without it, REQUIRED for the ones
|
|
106
|
+
# it must have; how it is read and written). Session.new, .from_h and
|
|
107
|
+
# #to_h all go by this table: a new field is one row (plus a
|
|
108
|
+
# SessionSetup member when a fork and a continue carry it). Messages
|
|
109
|
+
# change key type on the way (symbols in memory, strings on disk).
|
|
110
|
+
#
|
|
111
|
+
# A new field: a chi older than it (from the release after 0.130.0)
|
|
112
|
+
# keeps the key as it was (#extra_fields) but doesn't honour it, and the
|
|
113
|
+
# next newer chi reads it again. So it must be safe to come back
|
|
114
|
+
# unchanged after an older chi ran turns on the session: static data, or
|
|
115
|
+
# append-only data the older chi never appends to (review, verify_fresh,
|
|
116
|
+
# first_turn_id, verdicts); a cursor into messages, which an older chi
|
|
117
|
+
# would have moved, needs a check against what it points at. Only
|
|
118
|
+
# top-level keys are kept: the value objects (SessionReview,
|
|
119
|
+
# VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
|
|
120
|
+
# PromptNote, WorkingSetPin, LLMContextOverride) write their own members
|
|
121
|
+
# only, so an older chi still drops a new sub-key of one of them; add a
|
|
122
|
+
# top-level field when an older chi must keep it. A removed key would
|
|
123
|
+
# stay an extra forever: leave it out of the extras in .from_h then (no
|
|
124
|
+
# key was ever removed).
|
|
125
|
+
FIELDS = [
|
|
126
|
+
SessionField.plain("metadata_version", 1, fresh: METADATA_VERSION),
|
|
127
|
+
# The chi version that last saved the loaded file (#to_h writes this
|
|
128
|
+
# chi's VERSION); nil for a file from 0.130.0 or older.
|
|
129
|
+
SessionField.plain("written_by", nil, write: ->(_) { VERSION }),
|
|
130
|
+
SessionField.plain("id", REQUIRED),
|
|
131
|
+
SessionField.plain("mode", REQUIRED),
|
|
132
|
+
SessionField.plain("model_name", REQUIRED),
|
|
133
|
+
# The name model_name was typed as when that differs from the resolved
|
|
134
|
+
# ref stored in model_name (an alias: `small` for `gemma-small`), else
|
|
135
|
+
# nil. The models: lookup takes it (HostRegistry#lookup_names).
|
|
136
|
+
SessionField.plain("model_typed", nil),
|
|
137
|
+
SessionField.plain("working_directory", REQUIRED),
|
|
138
|
+
# The conversation (symbol keys in memory; .from_h symbolizes a
|
|
139
|
+
# file's); a running turn's checkpoint is saved in its place (#to_h).
|
|
140
|
+
SessionField.plain("messages", [], fresh: REQUIRED, write: MESSAGES),
|
|
141
|
+
SessionField.plain("created_at", REQUIRED),
|
|
142
|
+
SessionField.plain("updated_at", REQUIRED),
|
|
143
|
+
SessionField.plain("status", STATUS_IDLE),
|
|
144
|
+
SessionField.plain("last_prompt", ""),
|
|
145
|
+
SessionField.plain("first_preview", ""),
|
|
146
|
+
# The name the user gave the session (/title, TitleCommand), else nil:
|
|
147
|
+
# every list, card and tab shows it in place of #first_preview (see
|
|
148
|
+
# #display_title). A continue's new link inherits it.
|
|
149
|
+
SessionField.plain("title", nil),
|
|
150
|
+
SessionField.plain("test_run", false, cast: BOOLEAN, write: BOOLEAN),
|
|
151
|
+
# The question this session waits on (PendingQuestion), saved as
|
|
152
|
+
# session.json's "pending_question"; nil with none. A Hash (a saved
|
|
153
|
+
# record, string or symbol keys) is read with PendingQuestion.from_file.
|
|
154
|
+
SessionField.value("pending_question", :from_file, scrub: true) { PendingQuestion },
|
|
155
|
+
SessionField.plain("used_memory_names", [], cast: NAMES, write: LIST),
|
|
156
|
+
# The project the session was started in (ProjectScope.root_for its
|
|
157
|
+
# folder), stored because worktrees are deleted after a merge and a
|
|
158
|
+
# deleted folder no longer leads to its repository. nil outside a repo,
|
|
159
|
+
# and in files written before the field existed (see #project_root).
|
|
160
|
+
SessionField.plain("project_root", nil),
|
|
161
|
+
# Memories the session was started with (--memory) and memories hidden
|
|
162
|
+
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
163
|
+
# Names as given; the engine normalizes them.
|
|
164
|
+
SessionField.plain("preloaded_memory_names", [], cast: NAMES, write: LIST),
|
|
165
|
+
SessionField.plain("muted_memory_names", [], cast: NAMES, write: LIST),
|
|
166
|
+
# The model notes the session's system prompt carried at its last build
|
|
167
|
+
# (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
|
|
168
|
+
# Engine at each build (a start, a resume's or a woken worker's first
|
|
169
|
+
# turn, /model) and saved, so what a session ran with can be read later.
|
|
170
|
+
SessionField.list("prompt_notes") { PromptNote },
|
|
171
|
+
# The session that delegated this one (the `delegate` tool) or that a
|
|
172
|
+
# plugin forked it from (ctx.sessions.fork), else nil.
|
|
173
|
+
# Set before the spawn and kept on respawns, like preloaded_memory_names.
|
|
174
|
+
SessionField.plain("parent_id", nil, cast: ID),
|
|
175
|
+
# A `chi scratch` session: deleted when its terminal leaves (its worker
|
|
176
|
+
# deletes it too once its last client is gone), and by the next sweep
|
|
177
|
+
# (or `chi sessions clean`) when both died first.
|
|
178
|
+
SessionField.plain("scratch", false, cast: BOOLEAN),
|
|
179
|
+
# How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
|
|
180
|
+
# and the web's notifications (the hub sends it in the summary); nil
|
|
181
|
+
# before the first turn.
|
|
182
|
+
SessionField.value("last_turn", :from_file) { LastTurn },
|
|
183
|
+
# Verdict cards recorded by completed Verify turns, in append order.
|
|
184
|
+
# A missing field stays missing until the first verdict is recorded
|
|
185
|
+
# (#verdicts reads it as []). Cast on every set too.
|
|
186
|
+
SessionField.plain("verdicts", nil, cast: ->(values) { values.nil? ? nil : Array(values) }, write: VERDICTS,
|
|
187
|
+
optional: true),
|
|
188
|
+
# Started by the `delegate` tool (a fork has a parent_id too, but isn't
|
|
189
|
+
# one): see #delegate?.
|
|
190
|
+
SessionField.plain("delegate", false, cast: BOOLEAN),
|
|
191
|
+
# The session's own llm_context values (LLMContextOverride: strategy,
|
|
192
|
+
# apply rule, budget), before the model's; nil when it has none (an
|
|
193
|
+
# empty one is none). Saved, so a --resume and a respawned worker keep
|
|
194
|
+
# them; a plugin's fork copies them, a delegate child starts without.
|
|
195
|
+
# A file's parts it can't read are kept (LLMContextOverride.unread).
|
|
196
|
+
SessionField.level("llm_context", merge_unread: true) { LLMContextOverride },
|
|
197
|
+
# The session this one continues (SessionContinue.run): the
|
|
198
|
+
# previous link of a chain, "the next run of the same routine", else
|
|
199
|
+
# nil. Not a parent: archiving one link leaves the next alone. The chain
|
|
200
|
+
# itself is derived (SessionChain), never stored.
|
|
201
|
+
SessionField.plain("continues", nil, cast: ID),
|
|
202
|
+
# The session's own thinking level (:off, :low, :medium, :high), before
|
|
203
|
+
# the process default and the model's (Thinking.resolve); nil when it
|
|
204
|
+
# has none. Saved, so a --resume, a respawned worker and a continue keep
|
|
205
|
+
# it; a plugin's fork copies it, a delegate child starts without.
|
|
206
|
+
SessionField.level("thinking", unset: Thinking::DEFAULT.to_s) { Thinking },
|
|
207
|
+
# The session's own reasoning_history level (:drop, :turn, :all), before
|
|
208
|
+
# SAMAGOTCHI_REASONING_HISTORY and the model's (ReasoningHistory.resolve);
|
|
209
|
+
# nil when it has none. Saved and carried like thinking.
|
|
210
|
+
SessionField.level("reasoning_history", unset: ReasoningHistory::UNSET) { ReasoningHistory },
|
|
211
|
+
# The files chi --append-system-prompt put into the session's system
|
|
212
|
+
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
213
|
+
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
214
|
+
# (SessionSetup). Cast on every set too.
|
|
215
|
+
SessionField.plain("appended_prompt_files", [], cast: ->(paths) { Array(paths).map(&:to_s).reject(&:empty?).uniq },
|
|
216
|
+
write: LIST),
|
|
217
|
+
# The session's toolkit (Toolkit: the tools its prompts declare), as
|
|
218
|
+
# given when it was created; nil for a session from before toolkits,
|
|
219
|
+
# which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
|
|
220
|
+
# again from config, so a respawned worker declares the same tools; a
|
|
221
|
+
# continue and a fork copy it (SessionSetup.of), a delegate child gets
|
|
222
|
+
# the one its delegate call named, else toolkit.child
|
|
223
|
+
# (SessionManager.spawn_session). A Hash or a String reads too.
|
|
224
|
+
SessionField.value("toolkit", :from_file) { Toolkit },
|
|
225
|
+
# Why this session is on its toolkit (TOOLKIT_ORIGINS; anything else
|
|
226
|
+
# reads as chosen). Files from before the field read as chosen, so an
|
|
227
|
+
# old default session is never newly suggested. Cast on every set too.
|
|
228
|
+
SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN),
|
|
229
|
+
# The two parts of #settings (Session.new's settings:, .from_h composes
|
|
230
|
+
# them): its bundles part, then its core part.
|
|
231
|
+
SessionField.file_only("bundle_settings", {}, from: :settings, write: ->(settings) { settings.bundles }),
|
|
232
|
+
SessionField.file_only("config_settings", {}, from: :settings, write: ->(settings) { settings.core }),
|
|
233
|
+
# The mark of a turn a worker checkpointed and never finished
|
|
234
|
+
# (InterruptedTurn), as loaded; the next worker settles it and sets it
|
|
235
|
+
# to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
|
|
236
|
+
SessionField.value("interrupted_turn", :from_file) { InterruptedTurn },
|
|
237
|
+
# The session's own guardrails.parent_answers (hard|standard|soft), nil
|
|
238
|
+
# for none (config.yml's). It may only tighten config.yml's
|
|
239
|
+
# (Guardrails::ParentPolicy.effective); only the user loosens it
|
|
240
|
+
# (/parent-answers in a UI). Given at creation (chi --parent-answers,
|
|
241
|
+
# chi send --new --parent-answers); a respawn, a continue and a fork keep
|
|
242
|
+
# it (SessionSetup.of), a delegate child takes its parent's. Read as
|
|
243
|
+
# Guardrails::ParentPolicy.parse_level on every set: a level, or nil for
|
|
244
|
+
# none ("", "default"); a value that isn't a level is kept as written,
|
|
245
|
+
# stripped (read as hard: ParentPolicy.session_level).
|
|
246
|
+
SessionField.plain("parent_answers", nil, cast: PARENT_ANSWERS),
|
|
247
|
+
# The working set's items the user pinned (/working-set pin;
|
|
248
|
+
# WorkingSetPin), [] with none: the model's forget_outputs refuses
|
|
249
|
+
# their outputs. A respawn, a continue and a fork keep them
|
|
250
|
+
# (SessionSetup.of); a delegate child starts without.
|
|
251
|
+
SessionField.list("working_set_pins") { WorkingSetPin },
|
|
252
|
+
# A review session's subjects and source (SessionReview), nil for any
|
|
253
|
+
# other session.
|
|
254
|
+
SessionField.value("review", :of, optional: true) { SessionReview },
|
|
255
|
+
# The id the prompt a session is created with gets (SessionManager
|
|
256
|
+
# .spawn_session), so its first turn is a turn with an id like one sent
|
|
257
|
+
# later: the worker's first turn takes it as its origin, its reply file
|
|
258
|
+
# and last_turn carry it, and a wait for it (`chi send --new --wait`,
|
|
259
|
+
# chi web's create) can name it (`--wait --turn T ID`). nil for a
|
|
260
|
+
# session created idle, and for one saved before the field existed.
|
|
261
|
+
SessionField.plain("first_turn_id", nil, optional: true),
|
|
262
|
+
# A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
|
|
263
|
+
# nil for any other session. Set once, at spawn (VerifyFresh.start).
|
|
264
|
+
# Autoloaded on the first one (the bundle test kit's load budget).
|
|
265
|
+
SessionField.value("verify_fresh", :of, optional: true) { VerifyFreshOrigin }
|
|
266
|
+
].to_h { |field| [field.key, field] }.freeze
|
|
267
|
+
|
|
268
|
+
# FIELDS a session file has only when set: a session without them saves
|
|
269
|
+
# as it did before they existed.
|
|
270
|
+
OPTIONAL_FIELDS = FIELDS.values.select(&:optional).map(&:key).freeze
|
|
271
|
+
|
|
272
|
+
# Session.new's keywords: every FIELDS row's attr.
|
|
273
|
+
ATTRS = FIELDS.values.filter_map(&:attr).freeze
|
|
274
|
+
|
|
275
|
+
# @raise [ArgumentError] a keyword Session.new doesn't take, or a
|
|
276
|
+
# required one missing (Ruby's own wording)
|
|
277
|
+
def self.check_attrs!(attrs)
|
|
278
|
+
unknown = attrs.keys - ATTRS
|
|
279
|
+
raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
|
|
280
|
+
|
|
281
|
+
missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
|
|
282
|
+
raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
|
|
138
283
|
end
|
|
139
284
|
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
|
|
285
|
+
# The plain fields' readers and writers (each one's meaning is at its
|
|
286
|
+
# FIELDS row): a writer takes the value as given (Session.new and
|
|
287
|
+
# .from_h cast it), except the ones defined below.
|
|
288
|
+
attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
|
|
289
|
+
:created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
|
|
290
|
+
:used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
|
|
291
|
+
:continues, :first_turn_id
|
|
292
|
+
attr_writer :project_root, :delegate
|
|
293
|
+
attr_reader :appended_prompt_files, :written_by
|
|
144
294
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def last_turn=(value)
|
|
148
|
-
@last_turn = LastTurn.from_file(value)
|
|
295
|
+
def appended_prompt_files=(value)
|
|
296
|
+
@appended_prompt_files = load_field("appended_prompt_files", value)
|
|
149
297
|
end
|
|
150
298
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
299
|
+
# The value objects' readers and writers: a writer reads its value as
|
|
300
|
+
# its FIELDS row does (a file's Hash, the object itself; nil is none).
|
|
301
|
+
attr_reader :prompt_notes, :pending_question, :last_turn, :interrupted_turn, :toolkit, :review, :verify_fresh,
|
|
302
|
+
:working_set_pins
|
|
154
303
|
|
|
155
|
-
def
|
|
156
|
-
@
|
|
304
|
+
def prompt_notes=(value)
|
|
305
|
+
@prompt_notes = load_field("prompt_notes", value)
|
|
157
306
|
end
|
|
158
307
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
attr_reader :interrupted_turn
|
|
308
|
+
def pending_question=(value)
|
|
309
|
+
@pending_question = load_field("pending_question", value)
|
|
310
|
+
end
|
|
163
311
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
@interrupted_turn = InterruptedTurn.from_file(value)
|
|
312
|
+
def last_turn=(value)
|
|
313
|
+
@last_turn = load_field("last_turn", value)
|
|
167
314
|
end
|
|
168
315
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
# writes these messages and the marker instead of #messages and
|
|
172
|
-
# #interrupted_turn, so a save in the middle of the turn (a question
|
|
173
|
-
# asked, the checkpoint's own) keeps the turn so far on disk; #messages
|
|
174
|
-
# itself is never touched. The turn's end sets status idle before it
|
|
175
|
-
# saves, so that save writes the real messages, without a marker. Not
|
|
176
|
-
# saved; the worker clears it after each turn.
|
|
177
|
-
attr_accessor :turn_checkpoint
|
|
178
|
-
# The session's own llm_context values (LLMContextOverride: strategy,
|
|
179
|
-
# apply rule, budget), before the model's; nil when it has none.
|
|
180
|
-
# Saved, so a --resume and a respawned worker keep them; a plugin's
|
|
181
|
-
# fork copies them, a delegate child starts without.
|
|
182
|
-
attr_reader :llm_context
|
|
183
|
-
|
|
184
|
-
# @param value [LLMContextOverride, Hash, nil] an empty one is nil; a
|
|
185
|
-
# file's Hash keeps the fields it can't read (LLMContextOverride.unread),
|
|
186
|
-
# saved back as they were until the session sets its own
|
|
187
|
-
def llm_context=(value)
|
|
188
|
-
@llm_context_unread = LLMContextOverride.unread(value)
|
|
189
|
-
@llm_context = LLMContextOverride.from_file(value)
|
|
316
|
+
def interrupted_turn=(value)
|
|
317
|
+
@interrupted_turn = load_field("interrupted_turn", value)
|
|
190
318
|
end
|
|
191
319
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def llm_context_file
|
|
195
|
-
saved = @llm_context_unread.merge(@llm_context&.to_file || {})
|
|
196
|
-
saved.empty? ? nil : saved
|
|
320
|
+
def toolkit=(value)
|
|
321
|
+
@toolkit = load_field("toolkit", value)
|
|
197
322
|
end
|
|
198
323
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
# it; a plugin's fork copies it, a delegate child starts without.
|
|
203
|
-
attr_reader :thinking
|
|
324
|
+
def review=(value)
|
|
325
|
+
@review = load_field("review", value)
|
|
326
|
+
end
|
|
204
327
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def thinking=(value)
|
|
208
|
-
@thinking = Thinking.session_level(value)
|
|
209
|
-
unread = @thinking.nil? && value.is_a?(String) && !value.strip.casecmp?(Thinking::DEFAULT.to_s)
|
|
210
|
-
@thinking_unread = unread ? value : nil
|
|
328
|
+
def verify_fresh=(value)
|
|
329
|
+
@verify_fresh = load_field("verify_fresh", value)
|
|
211
330
|
end
|
|
212
331
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
332
|
+
def working_set_pins=(value)
|
|
333
|
+
@working_set_pins = load_field("working_set_pins", value)
|
|
334
|
+
end
|
|
216
335
|
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
attr_reader :reasoning_history
|
|
336
|
+
# Verdict cards recorded by completed Verify turns ([] before the
|
|
337
|
+
# first; the file has none then).
|
|
338
|
+
def verdicts = @verdicts || []
|
|
221
339
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def reasoning_history=(value)
|
|
225
|
-
@reasoning_history = ReasoningHistory.session_level(value)
|
|
226
|
-
unread = @reasoning_history.nil? && value.is_a?(String) && !value.strip.casecmp?(ReasoningHistory::UNSET)
|
|
227
|
-
@reasoning_history_unread = unread ? value : nil
|
|
340
|
+
def verdicts=(value)
|
|
341
|
+
@verdicts = load_field("verdicts", value)
|
|
228
342
|
end
|
|
229
343
|
|
|
230
|
-
# The session
|
|
231
|
-
#
|
|
232
|
-
|
|
344
|
+
# The session's own levels' and setup's readers and writers: a writer
|
|
345
|
+
# reads its value as its FIELDS row does; thinking, reasoning_history
|
|
346
|
+
# and llm_context keep what of it they couldn't read (SessionField.level).
|
|
347
|
+
attr_reader :llm_context, :thinking, :reasoning_history, :toolkit_origin, :parent_answers
|
|
233
348
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
# again from config, so a respawned worker declares the same tools; a
|
|
238
|
-
# continue and a fork copy it (SessionSetup.of), a delegate child gets
|
|
239
|
-
# the one its delegate call named, else toolkit.child
|
|
240
|
-
# (SessionManager.spawn_session).
|
|
241
|
-
attr_reader :toolkit
|
|
349
|
+
def llm_context=(value)
|
|
350
|
+
@llm_context = load_field("llm_context", value)
|
|
351
|
+
end
|
|
242
352
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
@toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
|
|
353
|
+
def thinking=(value)
|
|
354
|
+
@thinking = load_field("thinking", value)
|
|
246
355
|
end
|
|
247
356
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
357
|
+
def reasoning_history=(value)
|
|
358
|
+
@reasoning_history = load_field("reasoning_history", value)
|
|
359
|
+
end
|
|
251
360
|
|
|
252
361
|
def toolkit_origin=(value)
|
|
253
|
-
|
|
254
|
-
@toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
|
|
362
|
+
@toolkit_origin = load_field("toolkit_origin", value)
|
|
255
363
|
end
|
|
256
364
|
|
|
365
|
+
def parent_answers=(value)
|
|
366
|
+
@parent_answers = load_field("parent_answers", value)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
|
|
370
|
+
# (marker an InterruptedTurn). While +status+ is running, every save
|
|
371
|
+
# writes these messages and the marker instead of #messages and
|
|
372
|
+
# #interrupted_turn, so a save in the middle of the turn (a question
|
|
373
|
+
# asked, the checkpoint's own) keeps the turn so far on disk; #messages
|
|
374
|
+
# itself is never touched. The turn's end sets status idle before it
|
|
375
|
+
# saves, so that save writes the real messages, without a marker. Not
|
|
376
|
+
# saved; the worker clears it after each turn.
|
|
377
|
+
attr_accessor :turn_checkpoint
|
|
257
378
|
# The session's own settings (chi --set), a SessionConfig::Settings:
|
|
258
379
|
# empty for none and for a session from before them. Its core part
|
|
259
380
|
# ({key => value}: recap.enabled) is the file's config_settings, its
|
|
@@ -269,48 +390,9 @@ module Samagotchi
|
|
|
269
390
|
@settings = SessionConfig::Settings.of(value)
|
|
270
391
|
end
|
|
271
392
|
|
|
272
|
-
# The session's own guardrails.parent_answers (hard|standard|soft), nil
|
|
273
|
-
# for none (config.yml's). It may only tighten config.yml's
|
|
274
|
-
# (Guardrails::ParentPolicy.effective); only the user loosens it
|
|
275
|
-
# (/parent-answers in a UI). Given at creation (chi --parent-answers,
|
|
276
|
-
# chi send --new --parent-answers); a respawn, a continue and a fork keep
|
|
277
|
-
# it (SessionSetup.of), a delegate child takes its parent's.
|
|
278
|
-
attr_reader :parent_answers
|
|
279
|
-
|
|
280
|
-
# @param value [String, Symbol, nil] read as
|
|
281
|
-
# Guardrails::ParentPolicy.parse_level: a level, or nil for none ("",
|
|
282
|
-
# "default"); a value that isn't a level is kept as written, stripped
|
|
283
|
-
# (read as hard: ParentPolicy.session_level)
|
|
284
|
-
def parent_answers=(value)
|
|
285
|
-
level = Guardrails::ParentPolicy.parse_level(value)
|
|
286
|
-
@parent_answers = case level
|
|
287
|
-
when :unset then nil
|
|
288
|
-
when :invalid then value.to_s.strip
|
|
289
|
-
else level
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# A review session's subjects and source (SessionReview), nil for any
|
|
294
|
-
# other session.
|
|
295
|
-
attr_reader :review
|
|
296
|
-
|
|
297
|
-
# @param value [SessionReview, Hash, nil] the session file's form too
|
|
298
|
-
def review=(value)
|
|
299
|
-
@review = SessionReview.of(value)
|
|
300
|
-
end
|
|
301
|
-
|
|
302
393
|
# Whether this is a review session (chi review, the web's Start review).
|
|
303
394
|
def review? = !@review.nil?
|
|
304
395
|
|
|
305
|
-
# A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
|
|
306
|
-
# nil for any other session. Set once, at spawn (VerifyFresh.start).
|
|
307
|
-
attr_reader :verify_fresh
|
|
308
|
-
|
|
309
|
-
# @param value [VerifyFreshOrigin, Hash, nil] the session file's form too
|
|
310
|
-
def verify_fresh=(value)
|
|
311
|
-
@verify_fresh = value.nil? ? nil : VerifyFreshOrigin.of(value)
|
|
312
|
-
end
|
|
313
|
-
|
|
314
396
|
# The session whose participants.json names this session's
|
|
315
397
|
# participants: a Verify fresh child's parent (its requester's grant is
|
|
316
398
|
# the parent's), else the session itself.
|
|
@@ -323,17 +405,6 @@ module Samagotchi
|
|
|
323
405
|
|
|
324
406
|
TOOL_ROLES = %w[tool_response tool].freeze
|
|
325
407
|
|
|
326
|
-
# The working set's items the user pinned (/working-set pin;
|
|
327
|
-
# WorkingSetPin), [] with none: the model's forget_outputs refuses
|
|
328
|
-
# their outputs. A respawn, a continue and a fork keep them
|
|
329
|
-
# (SessionSetup.of); a delegate child starts without.
|
|
330
|
-
attr_reader :working_set_pins
|
|
331
|
-
|
|
332
|
-
# @param value [Array<WorkingSetPin, Hash>] the session file's form too
|
|
333
|
-
def working_set_pins=(value)
|
|
334
|
-
@working_set_pins = WorkingSetPin.list(value)
|
|
335
|
-
end
|
|
336
|
-
|
|
337
408
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
338
409
|
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
339
410
|
def stopped_by
|
|
@@ -353,7 +424,7 @@ module Samagotchi
|
|
|
353
424
|
attr_accessor :model_warning
|
|
354
425
|
|
|
355
426
|
# The delegates a continue moved to this new link (ChildMove;
|
|
356
|
-
#
|
|
427
|
+
# SessionContinue.run sets it); not saved.
|
|
357
428
|
attr_accessor :moved_children
|
|
358
429
|
|
|
359
430
|
# The attached context a continue took to this new link
|
|
@@ -361,7 +432,7 @@ module Samagotchi
|
|
|
361
432
|
attr_accessor :moved_context
|
|
362
433
|
|
|
363
434
|
# The text web `continue →` prefills in this new link
|
|
364
|
-
# (SessionChain.opener_for;
|
|
435
|
+
# (SessionChain.opener_for; SessionContinue.run sets it); not
|
|
365
436
|
# saved.
|
|
366
437
|
attr_accessor :opener
|
|
367
438
|
|
|
@@ -370,59 +441,18 @@ module Samagotchi
|
|
|
370
441
|
# FIELDS as they were. Never read by this chi.
|
|
371
442
|
attr_reader :extra_fields
|
|
372
443
|
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
def initialize(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
|
|
385
|
-
working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil, extra_fields: {})
|
|
386
|
-
@id = id
|
|
387
|
-
@metadata_version = metadata_version
|
|
388
|
-
@written_by = written_by
|
|
389
|
-
@mode = mode
|
|
390
|
-
@model_name = model_name
|
|
391
|
-
@model_typed = model_typed
|
|
392
|
-
@working_directory = working_directory
|
|
393
|
-
@messages = messages
|
|
394
|
-
@created_at = created_at
|
|
395
|
-
@updated_at = updated_at
|
|
396
|
-
@status = status
|
|
397
|
-
@last_prompt = last_prompt
|
|
398
|
-
@first_preview = first_preview
|
|
399
|
-
@first_turn_id = first_turn_id
|
|
400
|
-
@title = title
|
|
401
|
-
@test_run = !!test_run
|
|
402
|
-
self.pending_question = pending_question
|
|
403
|
-
@used_memory_names = self.class.name_list(used_memory_names)
|
|
404
|
-
@preloaded_memory_names = self.class.name_list(preloaded_memory_names)
|
|
405
|
-
@muted_memory_names = self.class.name_list(muted_memory_names)
|
|
406
|
-
self.prompt_notes = prompt_notes
|
|
407
|
-
@project_root = project_root
|
|
408
|
-
@parent_id = parent_id&.to_s
|
|
409
|
-
@scratch = !!scratch
|
|
410
|
-
self.last_turn = last_turn
|
|
411
|
-
@delegate = !!delegate
|
|
412
|
-
@continues = continues&.to_s
|
|
413
|
-
self.llm_context = llm_context
|
|
414
|
-
self.thinking = thinking
|
|
415
|
-
self.reasoning_history = reasoning_history
|
|
416
|
-
self.appended_prompt_files = appended_prompt_files
|
|
417
|
-
self.toolkit = toolkit
|
|
418
|
-
self.toolkit_origin = toolkit_origin
|
|
444
|
+
# Every FIELDS row's attr is a keyword (a missing required one or an
|
|
445
|
+
# unknown one raises ArgumentError, as Ruby's keywords do), given as is
|
|
446
|
+
# (cast by its row, as the writers cast); +settings+ is the
|
|
447
|
+
# settings pair's one value, +extra_fields+ the file's unknown keys.
|
|
448
|
+
def initialize(extra_fields: {}, settings: nil, **attrs)
|
|
449
|
+
self.class.check_attrs!(attrs)
|
|
450
|
+
# What the level fields couldn't read, by key (SessionField.level).
|
|
451
|
+
@unread = {}
|
|
452
|
+
FIELDS.each_value do |field|
|
|
453
|
+
instance_variable_set(field.ivar, load_field(field.key, attrs.fetch(field.attr) { field.fresh_value })) if field.attr
|
|
454
|
+
end
|
|
419
455
|
self.settings = settings
|
|
420
|
-
self.parent_answers = parent_answers
|
|
421
|
-
self.working_set_pins = working_set_pins
|
|
422
|
-
self.review = review
|
|
423
|
-
self.verdicts = verdicts
|
|
424
|
-
self.verify_fresh = verify_fresh
|
|
425
|
-
self.interrupted_turn = interrupted_turn
|
|
426
456
|
@extra_fields = extra_fields.to_h { |key, value| [key.to_s, value] }.freeze
|
|
427
457
|
@turn_checkpoint = nil
|
|
428
458
|
@archived = false
|
|
@@ -452,6 +482,20 @@ module Samagotchi
|
|
|
452
482
|
@preloaded_memory_names.include?(DELEGATE_MEMORY)
|
|
453
483
|
end
|
|
454
484
|
|
|
485
|
+
# The turn state (idle/running/…) the lists show: the saved status,
|
|
486
|
+
# except a "running" with no live +owner+ (an OwnerLock::Owner, or nil):
|
|
487
|
+
# the worker that saved it died mid-turn, so nothing runs. The one rule
|
|
488
|
+
# behind chi web's rows and pages (Web::SessionSummary.displayed_status,
|
|
489
|
+
# which prefers a live worker's snapshot) and `chi sessions list`
|
|
490
|
+
# (SessionManager.session_summaries and the plain table).
|
|
491
|
+
# @param owner [OwnerLock::Owner, nil] the session's owner now
|
|
492
|
+
# @return [String]
|
|
493
|
+
def displayed_status(owner:)
|
|
494
|
+
return status unless status == STATUS_RUNNING
|
|
495
|
+
|
|
496
|
+
owner ? status : STATUS_IDLE
|
|
497
|
+
end
|
|
498
|
+
|
|
455
499
|
# The question this session waits on, as the lists show it: {id:, kind:}
|
|
456
500
|
# with kind "question" (the model's), "approval", "hook" or "continue"
|
|
457
501
|
# (the step-limit question, asked between turns), +relayed_to+ and
|
|
@@ -501,16 +545,20 @@ module Samagotchi
|
|
|
501
545
|
# Build a new, unsaved session.
|
|
502
546
|
# @param messages [Array<Hash>] a conversation to start from (a fork's
|
|
503
547
|
# seed); [] by default
|
|
504
|
-
# @param setup [SessionSetup] the session's own settings (none by
|
|
548
|
+
# @param setup [SessionSetup, nil] the session's own settings (none by
|
|
549
|
+
# default): each member is the field of the same name (a member that
|
|
550
|
+
# isn't one is an ArgumentError), a toolkit_origin of none is chosen
|
|
505
551
|
# @param now [Time] its created_at and updated_at (a spec's clock)
|
|
506
552
|
# @raise [Toolkit::Refused] its toolkit (setup's, else the configured
|
|
507
553
|
# one SessionManager.spawn_session gives it) builds on a config kit
|
|
508
554
|
# with an invalid groups: (Toolkit.check_new!)
|
|
509
555
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
510
556
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
511
|
-
scratch: false, model_typed: nil, delegate: false, setup:
|
|
557
|
+
scratch: false, model_typed: nil, delegate: false, setup: nil, continues: nil,
|
|
512
558
|
review: nil, now: Time.now)
|
|
513
|
-
|
|
559
|
+
own = setup.to_h
|
|
560
|
+
own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
|
|
561
|
+
Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
|
|
514
562
|
now = now.iso8601(3)
|
|
515
563
|
resolved_test = if test_run.nil?
|
|
516
564
|
test_session_env?
|
|
@@ -535,17 +583,9 @@ module Samagotchi
|
|
|
535
583
|
parent_id: parent_id,
|
|
536
584
|
scratch: scratch,
|
|
537
585
|
delegate: delegate,
|
|
538
|
-
llm_context: setup.llm_context,
|
|
539
|
-
thinking: setup.thinking,
|
|
540
|
-
reasoning_history: setup.reasoning_history,
|
|
541
|
-
appended_prompt_files: setup.appended_prompt_files,
|
|
542
|
-
toolkit: setup.toolkit,
|
|
543
|
-
toolkit_origin: setup.toolkit_origin || "chosen",
|
|
544
|
-
settings: setup.settings,
|
|
545
|
-
parent_answers: setup.parent_answers,
|
|
546
|
-
working_set_pins: setup.working_set_pins,
|
|
547
586
|
continues: continues,
|
|
548
|
-
review: review
|
|
587
|
+
review: review,
|
|
588
|
+
**own
|
|
549
589
|
)
|
|
550
590
|
end
|
|
551
591
|
|
|
@@ -559,9 +599,12 @@ module Samagotchi
|
|
|
559
599
|
valid_id?(session_id) && File.exist?(session_path(session_id, state_dir: state_dir))
|
|
560
600
|
end
|
|
561
601
|
|
|
602
|
+
# @raise [NotFound] no such session (an id that isn't one too)
|
|
562
603
|
def self.load(session_id, state_dir: default_state_dir)
|
|
604
|
+
raise NotFound, session_id unless valid_id?(session_id)
|
|
605
|
+
|
|
563
606
|
path = session_path(session_id, state_dir: state_dir)
|
|
564
|
-
raise
|
|
607
|
+
raise NotFound, session_id unless File.exist?(path)
|
|
565
608
|
|
|
566
609
|
session = from_h(JSON.parse(File.read(path)))
|
|
567
610
|
session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
|
|
@@ -571,84 +614,12 @@ module Samagotchi
|
|
|
571
614
|
raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
|
|
572
615
|
end
|
|
573
616
|
|
|
574
|
-
# A field a session file must have (KeyError without it).
|
|
575
|
-
REQUIRED = Object.new.freeze
|
|
576
|
-
|
|
577
|
-
# The fields of <id>.json, in the order #save writes them: key => the
|
|
578
|
-
# value for a file without it (REQUIRED for the ones it must have).
|
|
579
|
-
# .from_h and #to_h both go by this table; messages change key type on
|
|
580
|
-
# the way (symbols in memory, strings on disk); last_turn, prompt_notes
|
|
581
|
-
# and pending_question are value objects in memory (LastTurn,
|
|
582
|
-
# PromptNote, PendingQuestion).
|
|
583
|
-
#
|
|
584
|
-
# A new field: a chi older than it (from the release after 0.130.0)
|
|
585
|
-
# keeps the key as it was (#extra_fields) but doesn't honour it, and the
|
|
586
|
-
# next newer chi reads it again. So it must be safe to come back
|
|
587
|
-
# unchanged after an older chi ran turns on the session: static data, or
|
|
588
|
-
# append-only data the older chi never appends to (review, verify_fresh,
|
|
589
|
-
# first_turn_id, verdicts); a cursor into messages, which an older chi
|
|
590
|
-
# would have moved, needs a check against what it points at. Only
|
|
591
|
-
# top-level keys are kept: the value objects (SessionReview,
|
|
592
|
-
# VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
|
|
593
|
-
# PromptNote, WorkingSetPin, LLMContextOverride) write their own members
|
|
594
|
-
# only, so an older chi still drops a new sub-key of one of them; add a
|
|
595
|
-
# top-level field when an older chi must keep it. A removed key would
|
|
596
|
-
# stay an extra forever: leave it out of the extras in .from_h then (no
|
|
597
|
-
# key was ever removed).
|
|
598
|
-
FIELDS = {
|
|
599
|
-
"metadata_version" => 1,
|
|
600
|
-
"written_by" => nil,
|
|
601
|
-
"id" => REQUIRED,
|
|
602
|
-
"mode" => REQUIRED,
|
|
603
|
-
"model_name" => REQUIRED,
|
|
604
|
-
"model_typed" => nil,
|
|
605
|
-
"working_directory" => REQUIRED,
|
|
606
|
-
"messages" => [],
|
|
607
|
-
"created_at" => REQUIRED,
|
|
608
|
-
"updated_at" => REQUIRED,
|
|
609
|
-
"status" => STATUS_IDLE,
|
|
610
|
-
"last_prompt" => "",
|
|
611
|
-
"first_preview" => "",
|
|
612
|
-
"title" => nil,
|
|
613
|
-
"test_run" => false,
|
|
614
|
-
"pending_question" => nil,
|
|
615
|
-
"used_memory_names" => [],
|
|
616
|
-
"project_root" => nil,
|
|
617
|
-
"preloaded_memory_names" => [],
|
|
618
|
-
"muted_memory_names" => [],
|
|
619
|
-
"prompt_notes" => [],
|
|
620
|
-
"parent_id" => nil,
|
|
621
|
-
"scratch" => false,
|
|
622
|
-
"last_turn" => nil,
|
|
623
|
-
"verdicts" => nil,
|
|
624
|
-
"delegate" => false,
|
|
625
|
-
"llm_context" => nil,
|
|
626
|
-
"continues" => nil,
|
|
627
|
-
"thinking" => nil,
|
|
628
|
-
"reasoning_history" => nil,
|
|
629
|
-
"appended_prompt_files" => [],
|
|
630
|
-
"toolkit" => nil,
|
|
631
|
-
"toolkit_origin" => "chosen",
|
|
632
|
-
"bundle_settings" => {},
|
|
633
|
-
"config_settings" => {},
|
|
634
|
-
"interrupted_turn" => nil,
|
|
635
|
-
"parent_answers" => nil,
|
|
636
|
-
"working_set_pins" => [],
|
|
637
|
-
"review" => nil,
|
|
638
|
-
"first_turn_id" => nil,
|
|
639
|
-
"verify_fresh" => nil
|
|
640
|
-
}.freeze
|
|
641
|
-
|
|
642
|
-
# FIELDS a session file has only when set: a session without them saves
|
|
643
|
-
# as it did before they existed.
|
|
644
|
-
OPTIONAL_FIELDS = %w[review first_turn_id verdicts verify_fresh].freeze
|
|
645
|
-
|
|
646
617
|
# A session from a parsed session file. +messages+ false leaves the
|
|
647
618
|
# conversation out (the lists' lightweight summaries).
|
|
648
619
|
# @raise [KeyError] for a missing REQUIRED field
|
|
649
620
|
def self.from_h(data, messages: true)
|
|
650
|
-
attrs = FIELDS.to_h do |key,
|
|
651
|
-
[key.to_sym,
|
|
621
|
+
attrs = FIELDS.to_h do |key, field|
|
|
622
|
+
[key.to_sym, field.required? ? data.fetch(key) : data.fetch(key, field.default)]
|
|
652
623
|
end
|
|
653
624
|
attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
|
|
654
625
|
attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
|
|
@@ -667,14 +638,41 @@ module Samagotchi
|
|
|
667
638
|
end
|
|
668
639
|
end
|
|
669
640
|
|
|
641
|
+
# What every surface says when an id (or a prefix) names no session:
|
|
642
|
+
# this class owns the words, callers add their context around
|
|
643
|
+
# #message. +given+ is what was asked for, as given.
|
|
644
|
+
class NotFound < ArgumentError
|
|
645
|
+
attr_reader :given
|
|
646
|
+
|
|
647
|
+
def initialize(given)
|
|
648
|
+
@given = given.to_s
|
|
649
|
+
super("no session #{@given[0, 80]}")
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# The full id of an existing session that +given+ (an id or a unique
|
|
654
|
+
# prefix) names: the one lookup the commands, SessionAccess::Local, chi
|
|
655
|
+
# web's routes and the tools share.
|
|
656
|
+
# @raise [NotFound] none (an id that isn't one too)
|
|
657
|
+
# @raise [AmbiguousId] a prefix of several
|
|
658
|
+
def self.resolve!(given, state_dir: default_state_dir)
|
|
659
|
+
raise NotFound, given unless valid_id?(given.to_s)
|
|
660
|
+
|
|
661
|
+
id = resolve_id(given, state_dir: state_dir)
|
|
662
|
+
raise NotFound, given unless exist?(id, state_dir: state_dir)
|
|
663
|
+
|
|
664
|
+
id
|
|
665
|
+
end
|
|
666
|
+
|
|
670
667
|
# A session id or a unique prefix of one (like git's): the full id. An
|
|
671
|
-
# unknown one comes back as is
|
|
668
|
+
# unknown one comes back as is (resolve! raises NotFound for it); one that
|
|
672
669
|
# names several sessions raises AmbiguousId, which lists them.
|
|
673
670
|
def self.resolve_id(id_or_prefix, state_dir: default_state_dir)
|
|
674
671
|
id = id_or_prefix.to_s
|
|
675
672
|
return id unless valid_id?(id) && !File.exist?(session_path(id, state_dir: state_dir))
|
|
676
673
|
|
|
677
|
-
matches = Dir.glob(File.join(state_dir, "#{id}*#{FILE_EXT}"))
|
|
674
|
+
matches = Dir.glob(File.join(state_dir, "#{id}*#{SessionPaths::FILE_EXT}"))
|
|
675
|
+
.map { |path| File.basename(path, SessionPaths::FILE_EXT) }.sort
|
|
678
676
|
return matches.fetch(0, id) if matches.size <= 1
|
|
679
677
|
|
|
680
678
|
lines = matches.map do |match|
|
|
@@ -700,7 +698,7 @@ module Samagotchi
|
|
|
700
698
|
sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
|
|
701
699
|
sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
|
|
702
700
|
|
|
703
|
-
sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
|
|
701
|
+
sessions = Dir.glob(File.join(state_dir, "*#{SessionPaths::FILE_EXT}")).filter_map { |path| summary_from_file(path) }
|
|
704
702
|
sessions.each { |s| s.archived = ArchiveStore.archived?(session_dir(s.id, state_dir: state_dir)) }
|
|
705
703
|
sessions.reject!(&:archived) unless include_archived
|
|
706
704
|
if project_root
|
|
@@ -780,40 +778,24 @@ module Samagotchi
|
|
|
780
778
|
# messages.
|
|
781
779
|
def to_h
|
|
782
780
|
checkpoint = checkpointed
|
|
783
|
-
FIELDS.
|
|
784
|
-
value = case key
|
|
785
|
-
when "messages"
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
|
|
789
|
-
when "written_by" then VERSION
|
|
790
|
-
when "test_run" then !!@test_run
|
|
791
|
-
when "delegate" then @delegate
|
|
792
|
-
when "llm_context" then llm_context_file
|
|
793
|
-
when "thinking" then thinking_file
|
|
794
|
-
when "reasoning_history" then reasoning_history_file
|
|
795
|
-
when "toolkit" then @toolkit&.to_file
|
|
796
|
-
when "bundle_settings" then @settings.bundles
|
|
797
|
-
when "config_settings" then @settings.core
|
|
798
|
-
when "prompt_notes" then @prompt_notes.map(&:to_file)
|
|
799
|
-
when "last_turn" then @last_turn&.to_file
|
|
800
|
-
when "working_set_pins" then @working_set_pins.map(&:to_file)
|
|
801
|
-
when "review" then @review&.to_file
|
|
802
|
-
when "verify_fresh" then @verify_fresh&.to_file
|
|
803
|
-
when "verdicts"
|
|
804
|
-
@verdicts&.map do |record|
|
|
805
|
-
scrub_utf8(record.to_h do |key, entry|
|
|
806
|
-
[key.to_s, entry.is_a?(Hash) ? stringify_message_keys(entry) : entry]
|
|
807
|
-
end)
|
|
808
|
-
end
|
|
809
|
-
when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
|
|
810
|
-
Array(instance_variable_get(:"@#{key}"))
|
|
811
|
-
else instance_variable_get(:"@#{key}")
|
|
781
|
+
FIELDS.each_value.with_object({}) do |field, file|
|
|
782
|
+
value = case field.key
|
|
783
|
+
when "messages" then field.dump(checkpoint ? checkpoint[:messages] : @messages)
|
|
784
|
+
when "interrupted_turn" then field.dump(checkpoint ? checkpoint[:marker] : @interrupted_turn)
|
|
785
|
+
else field.dump(instance_variable_get(field.ivar), @unread[field.key])
|
|
812
786
|
end
|
|
813
|
-
[key
|
|
814
|
-
end.reject { |key,
|
|
815
|
-
|
|
787
|
+
file[field.key] = value unless value.nil? && field.optional
|
|
788
|
+
end.merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# +value+ as +key+'s FIELDS row reads it (its writer's value); what of
|
|
792
|
+
# it a level field couldn't read is kept for #to_h.
|
|
793
|
+
def load_field(key, value)
|
|
794
|
+
field = FIELDS.fetch(key)
|
|
795
|
+
@unread[key] = field.unread.call(value) if field.unread
|
|
796
|
+
field.load(value)
|
|
816
797
|
end
|
|
798
|
+
private :load_field
|
|
817
799
|
|
|
818
800
|
# The running turn's checkpoint, while the turn runs (#turn_checkpoint).
|
|
819
801
|
def checkpointed
|
|
@@ -832,9 +814,9 @@ module Samagotchi
|
|
|
832
814
|
|
|
833
815
|
# Mark a session as stopped (STOPPED_FILE); its session file stays as
|
|
834
816
|
# it is.
|
|
835
|
-
# @raise [
|
|
817
|
+
# @raise [NotFound] when there is no such session
|
|
836
818
|
def self.mark_stopped(session_id, state_dir: default_state_dir)
|
|
837
|
-
raise
|
|
819
|
+
raise NotFound, session_id unless exist?(session_id, state_dir: state_dir)
|
|
838
820
|
|
|
839
821
|
dir = session_dir(session_id, state_dir: state_dir)
|
|
840
822
|
FileUtils.mkdir_p(dir)
|
|
@@ -873,45 +855,6 @@ module Samagotchi
|
|
|
873
855
|
nil
|
|
874
856
|
end
|
|
875
857
|
|
|
876
|
-
# An id that is not a session id: it could name a path outside the
|
|
877
|
-
# sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
|
|
878
|
-
class InvalidId < ArgumentError; end
|
|
879
|
-
|
|
880
|
-
# Ids are SecureRandom.uuid; anything made of letters, digits, "-" and
|
|
881
|
-
# "_" (up to 128, not starting with "-") is taken, so a UUID prefix the
|
|
882
|
-
# CLI resolves passes too. This is the one check every id from outside
|
|
883
|
-
# (the bridge, the web routes, chi send/note, delegate) meets before it
|
|
884
|
-
# becomes a path: session_dir and the session file path refuse others.
|
|
885
|
-
VALID_ID = /\A[A-Za-z0-9_][A-Za-z0-9_-]{0,127}\z/
|
|
886
|
-
|
|
887
|
-
def self.valid_id?(session_id)
|
|
888
|
-
session_id.is_a?(String) && VALID_ID.match?(session_id)
|
|
889
|
-
end
|
|
890
|
-
|
|
891
|
-
# @raise [InvalidId] unless valid_id?(session_id)
|
|
892
|
-
def self.check_id!(session_id)
|
|
893
|
-
return session_id if valid_id?(session_id)
|
|
894
|
-
|
|
895
|
-
raise InvalidId, "invalid session id: #{session_id.to_s[0, 80].inspect}"
|
|
896
|
-
end
|
|
897
|
-
|
|
898
|
-
# Directory for a specific session (holds IPC files alongside session.json).
|
|
899
|
-
# @raise [InvalidId] for an id that isn't one (valid_id?)
|
|
900
|
-
def self.session_dir(session_id, state_dir: default_state_dir)
|
|
901
|
-
File.join(state_dir, check_id!(session_id))
|
|
902
|
-
end
|
|
903
|
-
|
|
904
|
-
# A session's <id>.json.
|
|
905
|
-
# @raise [InvalidId] for an id that isn't one (valid_id?)
|
|
906
|
-
def self.session_file(session_id, state_dir: default_state_dir)
|
|
907
|
-
File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
|
|
908
|
-
end
|
|
909
|
-
|
|
910
|
-
# XDG-aware sessions directory.
|
|
911
|
-
def self.default_state_dir(env: ENV)
|
|
912
|
-
File.join(Paths.state_home(env: env), STATE_SUBDIR)
|
|
913
|
-
end
|
|
914
|
-
|
|
915
858
|
# What names the session: its #title when it has one, else its
|
|
916
859
|
# #first_preview.
|
|
917
860
|
def display_title
|
|
@@ -970,24 +913,6 @@ module Samagotchi
|
|
|
970
913
|
text.empty? ? nil : self.class.preview_of("note: #{text}")
|
|
971
914
|
end
|
|
972
915
|
|
|
973
|
-
def stringify_message_keys(hash)
|
|
974
|
-
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
975
|
-
end
|
|
976
|
-
|
|
977
|
-
# The last guard before JSON.generate, which raises on bytes that
|
|
978
|
-
# aren't UTF-8: a save that raised would lose the whole conversation.
|
|
979
|
-
# ToolRunner already scrubs tool output; this covers any other source.
|
|
980
|
-
def scrub_utf8(obj)
|
|
981
|
-
case obj
|
|
982
|
-
when String
|
|
983
|
-
str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
|
|
984
|
-
str.valid_encoding? ? str : str.scrub("?")
|
|
985
|
-
when Array then obj.map { |element| scrub_utf8(element) }
|
|
986
|
-
when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
|
|
987
|
-
else obj
|
|
988
|
-
end
|
|
989
|
-
end
|
|
990
|
-
|
|
991
916
|
class << self
|
|
992
917
|
private
|
|
993
918
|
|