samagotchi 0.156.0 → 0.158.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 +70 -1
- data/README.md +1 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +18 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +4 -1
- data/docs/cli.md +29 -24
- data/docs/configuration.md +43 -9
- data/docs/context.md +5 -3
- data/docs/guardrails.md +12 -7
- data/docs/hooks.md +53 -83
- data/docs/internals/bridge.md +84 -7
- data/docs/internals/bundles.md +69 -46
- data/docs/internals/context-telemetry.md +4 -3
- data/docs/internals/engine.md +23 -7
- data/docs/internals/participants.md +3 -2
- data/docs/internals/remote-sessions.md +5 -2
- data/docs/internals/web-frontend.md +3 -2
- data/docs/internals/working-set.md +1 -1
- data/docs/memory.md +51 -10
- data/docs/plugins.md +48 -11
- data/docs/releasing.md +6 -5
- data/docs/sessions.md +13 -13
- data/docs/sub-agent.md +17 -2
- data/docs/testing.md +56 -5
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +3 -2
- data/lib/samagotchi/bridge/card_places.rb +5 -7
- data/lib/samagotchi/bridge/card_store.rb +3 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
- data/lib/samagotchi/bridge/turn_drawings.rb +271 -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/bundle_command/check.rb +41 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +20 -45
- 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 +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/child_move.rb +6 -6
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +15 -21
- data/lib/samagotchi/cli/parent_wait.rb +8 -22
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_sources.rb +9 -6
- data/lib/samagotchi/context_window.rb +9 -12
- data/lib/samagotchi/effective_model.rb +57 -22
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/recap.rb +8 -4
- data/lib/samagotchi/engine.rb +30 -9
- data/lib/samagotchi/events.rb +3 -2
- data/lib/samagotchi/extension_load.rb +26 -44
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/host_address.rb +52 -0
- data/lib/samagotchi/host_registry.rb +57 -45
- data/lib/samagotchi/kernel_loop.rb +7 -3
- data/lib/samagotchi/llm/chat_loop.rb +6 -3
- data/lib/samagotchi/llm_context_strategy.rb +27 -63
- data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
- data/lib/samagotchi/memory_bundle/builder.rb +12 -35
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- 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_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
- data/lib/samagotchi/memory_bundle/installer.rb +212 -178
- data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
- 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 +26 -52
- data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
- data/lib/samagotchi/memory_bundle/status.rb +26 -30
- data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
- data/lib/samagotchi/memory_bundle/trash.rb +28 -18
- data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/memory_usage.rb +1 -1
- data/lib/samagotchi/model_lookup.rb +75 -0
- data/lib/samagotchi/model_profile.rb +1 -0
- data/lib/samagotchi/models_command.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/participant_mode.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +42 -10
- data/lib/samagotchi/plugin/loader.rb +75 -15
- data/lib/samagotchi/plugin/sessions.rb +1 -1
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/reasoning_history.rb +14 -20
- data/lib/samagotchi/recap_place.rb +26 -0
- 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/run_options.rb +1 -0
- data/lib/samagotchi/sampling_settings.rb +9 -16
- data/lib/samagotchi/self_report.rb +34 -35
- data/lib/samagotchi/send_command.rb +17 -23
- data/lib/samagotchi/session.rb +349 -441
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +5 -6
- data/lib/samagotchi/session_access/remote.rb +5 -3
- data/lib/samagotchi/session_field.rb +134 -0
- data/lib/samagotchi/session_inbox.rb +49 -1
- data/lib/samagotchi/session_manager/emptiness.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +29 -100
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_metrics.rb +7 -4
- 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/steer.rb +10 -0
- data/lib/samagotchi/steer_cut_after.rb +7 -14
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
- data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +3 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -4
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/thinking.rb +16 -21
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_response.rb +5 -3
- data/lib/samagotchi/tool_runner.rb +18 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/delegate.rb +60 -51
- data/lib/samagotchi/tools/delegate_relay.rb +5 -5
- data/lib/samagotchi/tools/delegate_result.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +41 -17
- data/lib/samagotchi/tools/list_sessions.rb +7 -7
- 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 +66 -21
- data/lib/samagotchi/web/message_display.rb +57 -7
- data/lib/samagotchi/web/participant_access.rb +1 -1
- data/lib/samagotchi/web/project_list.rb +3 -2
- 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/index.html +6 -1
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- 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 +3 -3
- data/lib/samagotchi/web/session_control.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +9 -17
- data/lib/samagotchi/web/session_show.rb +64 -2
- data/lib/samagotchi/web/session_summary.rb +36 -24
- data/lib/samagotchi/worker.rb +28 -5
- data/lib/samagotchi/worker_link.rb +3 -0
- metadata +22 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
- 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,323 @@ 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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
# A text the user typed or pasted (a prompt, a title): the file gets it
|
|
85
|
+
# as valid UTF-8 (nil stays nil), the session keeps what it was given.
|
|
86
|
+
SCRUBBED = ->(value) { SessionField.scrub_utf8(value) }
|
|
87
|
+
VERDICTS = lambda do |records|
|
|
88
|
+
records&.map do |record|
|
|
89
|
+
SessionField.scrub_utf8(record.to_h do |key, entry|
|
|
90
|
+
[key.to_s, entry.is_a?(Hash) ? SessionField.stringify_keys(entry) : entry]
|
|
91
|
+
end)
|
|
92
|
+
end
|
|
94
93
|
end
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
94
|
+
TOOLKIT_ORIGIN = lambda do |value|
|
|
95
|
+
origin = value.to_s
|
|
96
|
+
TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
|
|
97
|
+
end
|
|
98
|
+
TOOLKIT_ORIGIN_UNREAD = ->(value) { value if value.is_a?(String) && !TOOLKIT_ORIGINS.include?(value) }
|
|
99
|
+
# The kept value while there is one: the written one is the cast
|
|
100
|
+
# (never nil), so it can't come first as a level's does.
|
|
101
|
+
KEPT_FIRST = ->(written, kept) { kept || written }
|
|
102
|
+
PARENT_ANSWERS = lambda do |value|
|
|
103
|
+
case (level = Guardrails::ParentPolicy.parse_level(value))
|
|
104
|
+
when :unset then nil
|
|
105
|
+
when :invalid then value.to_s.strip
|
|
106
|
+
else level
|
|
107
|
+
end
|
|
105
108
|
end
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# nil. The models: lookup takes it (HostRegistry#lookup_names).
|
|
109
|
-
attr_accessor :model_typed
|
|
110
|
-
# The project the session was started in (ProjectScope.root_for its
|
|
111
|
-
# folder), stored because worktrees are deleted after a merge and a
|
|
112
|
-
# deleted folder no longer leads to its repository. nil outside a repo,
|
|
113
|
-
# and in files written before the field existed (see #project_root).
|
|
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 (SessionContinue.run): 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
|
|
109
|
+
private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :SCRUBBED, :VERDICTS, :TOOLKIT_ORIGIN, :TOOLKIT_ORIGIN_UNREAD, :KEPT_FIRST,
|
|
110
|
+
:PARENT_ANSWERS
|
|
135
111
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
112
|
+
# The fields of <id>.json, in the order #save writes them: key => its
|
|
113
|
+
# SessionField (the value for a file without it, REQUIRED for the ones
|
|
114
|
+
# it must have; how it is read and written). Session.new, .from_h and
|
|
115
|
+
# #to_h all go by this table: a new field is one row (plus a
|
|
116
|
+
# SessionSetup member when a fork and a continue carry it). Messages
|
|
117
|
+
# change key type on the way (symbols in memory, strings on disk).
|
|
118
|
+
#
|
|
119
|
+
# A new field: a chi older than it (from the release after 0.130.0)
|
|
120
|
+
# keeps the key as it was (#extra_fields) but doesn't honour it, and the
|
|
121
|
+
# next newer chi reads it again. So it must be safe to come back
|
|
122
|
+
# unchanged after an older chi ran turns on the session: static data, or
|
|
123
|
+
# append-only data the older chi never appends to (review, verify_fresh,
|
|
124
|
+
# first_turn_id, verdicts); a cursor into messages, which an older chi
|
|
125
|
+
# would have moved, needs a check against what it points at. Only
|
|
126
|
+
# top-level keys are kept: the value objects (SessionReview,
|
|
127
|
+
# VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
|
|
128
|
+
# PromptNote, WorkingSetPin, LLMContextOverride) write their own members
|
|
129
|
+
# only, so an older chi still drops a new sub-key of one of them; add a
|
|
130
|
+
# top-level field when an older chi must keep it. A removed key would
|
|
131
|
+
# stay an extra forever: leave it out of the extras in .from_h then (no
|
|
132
|
+
# key was ever removed).
|
|
133
|
+
FIELDS = [
|
|
134
|
+
SessionField.plain("metadata_version", 1, fresh: METADATA_VERSION),
|
|
135
|
+
# The chi version that last saved the loaded file (#to_h writes this
|
|
136
|
+
# chi's VERSION); nil for a file from 0.130.0 or older.
|
|
137
|
+
SessionField.plain("written_by", nil, write: ->(_) { VERSION }),
|
|
138
|
+
SessionField.plain("id", REQUIRED),
|
|
139
|
+
SessionField.plain("mode", REQUIRED),
|
|
140
|
+
SessionField.plain("model_name", REQUIRED),
|
|
141
|
+
# The name model_name was typed as when that differs from the resolved
|
|
142
|
+
# ref stored in model_name (an alias: `small` for `gemma-small`), else
|
|
143
|
+
# nil. The models: lookup takes it (HostRegistry#lookup_names).
|
|
144
|
+
SessionField.plain("model_typed", nil),
|
|
145
|
+
SessionField.plain("working_directory", REQUIRED),
|
|
146
|
+
# The conversation (symbol keys in memory; .from_h symbolizes a
|
|
147
|
+
# file's); a running turn's checkpoint is saved in its place (#to_h).
|
|
148
|
+
SessionField.plain("messages", [], fresh: REQUIRED, write: MESSAGES),
|
|
149
|
+
SessionField.plain("created_at", REQUIRED),
|
|
150
|
+
SessionField.plain("updated_at", REQUIRED),
|
|
151
|
+
SessionField.plain("status", STATUS_IDLE),
|
|
152
|
+
SessionField.plain("last_prompt", "", write: SCRUBBED),
|
|
153
|
+
SessionField.plain("first_preview", "", write: SCRUBBED),
|
|
154
|
+
# The name the user gave the session (/title, TitleCommand), else nil:
|
|
155
|
+
# every list, card and tab shows it in place of #first_preview (see
|
|
156
|
+
# #display_title). A continue's new link inherits it.
|
|
157
|
+
SessionField.plain("title", nil, write: SCRUBBED),
|
|
158
|
+
SessionField.plain("test_run", false, cast: BOOLEAN, write: BOOLEAN),
|
|
159
|
+
# The question this session waits on (PendingQuestion), saved as
|
|
160
|
+
# session.json's "pending_question"; nil with none. A Hash (a saved
|
|
161
|
+
# record, string or symbol keys) is read with PendingQuestion.from_file.
|
|
162
|
+
SessionField.value("pending_question", :from_file, scrub: true) { PendingQuestion },
|
|
163
|
+
SessionField.plain("used_memory_names", [], cast: NAMES, write: LIST),
|
|
164
|
+
# The project the session was started in (ProjectScope.root_for its
|
|
165
|
+
# folder), stored because worktrees are deleted after a merge and a
|
|
166
|
+
# deleted folder no longer leads to its repository. nil outside a repo,
|
|
167
|
+
# and in files written before the field existed (see #project_root).
|
|
168
|
+
SessionField.plain("project_root", nil),
|
|
169
|
+
# Memories the session was started with (--memory) and memories hidden
|
|
170
|
+
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
171
|
+
# Names as given; the engine normalizes them.
|
|
172
|
+
SessionField.plain("preloaded_memory_names", [], cast: NAMES, write: LIST),
|
|
173
|
+
SessionField.plain("muted_memory_names", [], cast: NAMES, write: LIST),
|
|
174
|
+
# The model notes the session's system prompt carried at its last build
|
|
175
|
+
# (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
|
|
176
|
+
# Engine at each build (a start, a resume's or a woken worker's first
|
|
177
|
+
# turn, /model) and saved, so what a session ran with can be read later.
|
|
178
|
+
SessionField.list("prompt_notes") { PromptNote },
|
|
179
|
+
# The session that delegated this one (the `delegate` tool) or that a
|
|
180
|
+
# plugin forked it from (ctx.sessions.fork), else nil.
|
|
181
|
+
# Set before the spawn and kept on respawns, like preloaded_memory_names.
|
|
182
|
+
SessionField.plain("parent_id", nil, cast: ID),
|
|
183
|
+
# A `chi scratch` session: deleted when its terminal leaves (its worker
|
|
184
|
+
# deletes it too once its last client is gone), and by the next sweep
|
|
185
|
+
# (or `chi sessions clean`) when both died first.
|
|
186
|
+
SessionField.plain("scratch", false, cast: BOOLEAN),
|
|
187
|
+
# How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
|
|
188
|
+
# and the web's notifications (the hub sends it in the summary); nil
|
|
189
|
+
# before the first turn.
|
|
190
|
+
SessionField.value("last_turn", :from_file) { LastTurn },
|
|
191
|
+
# Verdict cards recorded by completed Verify turns, in append order.
|
|
192
|
+
# A missing field stays missing until the first verdict is recorded
|
|
193
|
+
# (#verdicts reads it as []). Cast on every set too.
|
|
194
|
+
SessionField.plain("verdicts", nil, cast: ->(values) { values.nil? ? nil : Array(values) }, write: VERDICTS,
|
|
195
|
+
optional: true),
|
|
196
|
+
# Started by the `delegate` tool (a fork has a parent_id too, but isn't
|
|
197
|
+
# one): see #delegate?.
|
|
198
|
+
SessionField.plain("delegate", false, cast: BOOLEAN),
|
|
199
|
+
# The session's own llm_context values (LLMContextOverride: strategy,
|
|
200
|
+
# apply rule, budget), before the model's; nil when it has none (an
|
|
201
|
+
# empty one is none). Saved, so a --resume and a respawned worker keep
|
|
202
|
+
# them; a plugin's fork copies them, a delegate child starts without.
|
|
203
|
+
# A file's parts it can't read are kept (LLMContextOverride.unread).
|
|
204
|
+
SessionField.level("llm_context", merge_unread: true) { LLMContextOverride },
|
|
205
|
+
# The session this one continues (SessionContinue.run): the
|
|
206
|
+
# previous link of a chain, "the next run of the same routine", else
|
|
207
|
+
# nil. Not a parent: archiving one link leaves the next alone. The chain
|
|
208
|
+
# itself is derived (SessionChain), never stored.
|
|
209
|
+
SessionField.plain("continues", nil, cast: ID),
|
|
210
|
+
# The session's own thinking level (:off, :low, :medium, :high), before
|
|
211
|
+
# the process default and the model's (Thinking.resolve); nil when it
|
|
212
|
+
# has none. Saved, so a --resume, a respawned worker and a continue keep
|
|
213
|
+
# it; a plugin's fork copies it, a delegate child starts without.
|
|
214
|
+
SessionField.level("thinking", unset: Thinking::DEFAULT.to_s) { Thinking },
|
|
215
|
+
# The session's own reasoning_history level (:drop, :turn, :all), before
|
|
216
|
+
# SAMAGOTCHI_REASONING_HISTORY and the model's (ReasoningHistory.resolve);
|
|
217
|
+
# nil when it has none. Saved and carried like thinking.
|
|
218
|
+
SessionField.level("reasoning_history", unset: ReasoningHistory::UNSET) { ReasoningHistory },
|
|
219
|
+
# The files chi --append-system-prompt put into the session's system
|
|
220
|
+
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
221
|
+
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
222
|
+
# (SessionSetup). Cast on every set too.
|
|
223
|
+
SessionField.plain("appended_prompt_files", [], cast: ->(paths) { Array(paths).map(&:to_s).reject(&:empty?).uniq },
|
|
224
|
+
write: LIST),
|
|
225
|
+
# The session's toolkit (Toolkit: the tools its prompts declare), as
|
|
226
|
+
# given when it was created; nil for a session from before toolkits,
|
|
227
|
+
# which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
|
|
228
|
+
# again from config, so a respawned worker declares the same tools; a
|
|
229
|
+
# continue and a fork copy it (SessionSetup.of), a delegate child gets
|
|
230
|
+
# the one its delegate call named, else toolkit.child
|
|
231
|
+
# (SessionManager.spawn_session). A Hash or a String reads too.
|
|
232
|
+
SessionField.value("toolkit", :from_file) { Toolkit },
|
|
233
|
+
# Why this session is on its toolkit (TOOLKIT_ORIGINS; anything else
|
|
234
|
+
# reads as chosen). Files from before the field read as chosen, so an
|
|
235
|
+
# old default session is never newly suggested. Cast on every set too.
|
|
236
|
+
# An unknown String (a newer chi's origin) is written back as it was
|
|
237
|
+
# (the Unread rule), until the session sets its own: the kept value
|
|
238
|
+
# wins over the cast chosen.
|
|
239
|
+
SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN, unread: TOOLKIT_ORIGIN_UNREAD,
|
|
240
|
+
keep: KEPT_FIRST),
|
|
241
|
+
# The two parts of #settings (Session.new's settings:, .from_h composes
|
|
242
|
+
# them): its bundles part, then its core part.
|
|
243
|
+
SessionField.file_only("bundle_settings", {}, from: :settings, write: ->(settings) { settings.bundles }),
|
|
244
|
+
SessionField.file_only("config_settings", {}, from: :settings, write: ->(settings) { settings.core }),
|
|
245
|
+
# The mark of a turn a worker checkpointed and never finished
|
|
246
|
+
# (InterruptedTurn), as loaded; the next worker settles it and sets it
|
|
247
|
+
# to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
|
|
248
|
+
SessionField.value("interrupted_turn", :from_file) { InterruptedTurn },
|
|
249
|
+
# The session's own guardrails.parent_answers (hard|standard|soft), nil
|
|
250
|
+
# for none (config.yml's). It may only tighten config.yml's
|
|
251
|
+
# (Guardrails::ParentPolicy.effective); only the user loosens it
|
|
252
|
+
# (/parent-answers in a UI). Given at creation (chi --parent-answers,
|
|
253
|
+
# chi send --new --parent-answers); a respawn, a continue and a fork keep
|
|
254
|
+
# it (SessionSetup.of), a delegate child takes its parent's. Read as
|
|
255
|
+
# Guardrails::ParentPolicy.parse_level on every set: a level, or nil for
|
|
256
|
+
# none ("", "default"); a value that isn't a level is kept as written,
|
|
257
|
+
# stripped (read as hard: ParentPolicy.session_level).
|
|
258
|
+
SessionField.plain("parent_answers", nil, cast: PARENT_ANSWERS),
|
|
259
|
+
# The working set's items the user pinned (/working-set pin;
|
|
260
|
+
# WorkingSetPin), [] with none: the model's forget_outputs refuses
|
|
261
|
+
# their outputs. A respawn, a continue and a fork keep them
|
|
262
|
+
# (SessionSetup.of); a delegate child starts without.
|
|
263
|
+
SessionField.list("working_set_pins") { WorkingSetPin },
|
|
264
|
+
# A review session's subjects and source (SessionReview), nil for any
|
|
265
|
+
# other session.
|
|
266
|
+
SessionField.value("review", :of, optional: true) { SessionReview },
|
|
267
|
+
# The id the prompt a session is created with gets (SessionManager
|
|
268
|
+
# .spawn_session), so its first turn is a turn with an id like one sent
|
|
269
|
+
# later: the worker's first turn takes it as its origin, its reply file
|
|
270
|
+
# and last_turn carry it, and a wait for it (`chi send --new --wait`,
|
|
271
|
+
# chi web's create) can name it (`--wait --turn T ID`). nil for a
|
|
272
|
+
# session created idle, and for one saved before the field existed.
|
|
273
|
+
SessionField.plain("first_turn_id", nil, optional: true),
|
|
274
|
+
# A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
|
|
275
|
+
# nil for any other session. Set once, at spawn (VerifyFresh.start).
|
|
276
|
+
# Autoloaded on the first one (the bundle test kit's load budget).
|
|
277
|
+
SessionField.value("verify_fresh", :of, optional: true) { VerifyFreshOrigin }
|
|
278
|
+
].to_h { |field| [field.key, field] }.freeze
|
|
139
279
|
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
attr_reader :last_turn
|
|
280
|
+
# FIELDS a session file has only when set: a session without them saves
|
|
281
|
+
# as it did before they existed.
|
|
282
|
+
OPTIONAL_FIELDS = FIELDS.values.select(&:optional).map(&:key).freeze
|
|
144
283
|
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
284
|
+
# Session.new's keywords: every FIELDS row's attr.
|
|
285
|
+
ATTRS = FIELDS.values.filter_map(&:attr).freeze
|
|
286
|
+
|
|
287
|
+
# The plain fields' readers and writers (each one's meaning is at its
|
|
288
|
+
# FIELDS row): a writer takes the value as given (Session.new and
|
|
289
|
+
# .from_h cast it), except the ones defined below.
|
|
290
|
+
attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
|
|
291
|
+
:created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
|
|
292
|
+
:used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
|
|
293
|
+
:continues, :first_turn_id
|
|
294
|
+
attr_writer :project_root, :delegate
|
|
295
|
+
attr_reader :appended_prompt_files, :written_by
|
|
296
|
+
|
|
297
|
+
def appended_prompt_files=(value)
|
|
298
|
+
@appended_prompt_files = load_field("appended_prompt_files", value)
|
|
149
299
|
end
|
|
150
300
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
301
|
+
# The value objects' readers and writers: a writer reads its value as
|
|
302
|
+
# its FIELDS row does (a file's Hash, the object itself; nil is none).
|
|
303
|
+
attr_reader :prompt_notes, :pending_question, :last_turn, :interrupted_turn, :toolkit, :review, :verify_fresh,
|
|
304
|
+
:working_set_pins
|
|
154
305
|
|
|
155
|
-
def
|
|
156
|
-
@
|
|
306
|
+
def prompt_notes=(value)
|
|
307
|
+
@prompt_notes = load_field("prompt_notes", value)
|
|
157
308
|
end
|
|
158
309
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
attr_reader :interrupted_turn
|
|
310
|
+
def pending_question=(value)
|
|
311
|
+
@pending_question = load_field("pending_question", value)
|
|
312
|
+
end
|
|
163
313
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
@interrupted_turn = InterruptedTurn.from_file(value)
|
|
314
|
+
def last_turn=(value)
|
|
315
|
+
@last_turn = load_field("last_turn", value)
|
|
167
316
|
end
|
|
168
317
|
|
|
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)
|
|
318
|
+
def interrupted_turn=(value)
|
|
319
|
+
@interrupted_turn = load_field("interrupted_turn", value)
|
|
190
320
|
end
|
|
191
321
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def llm_context_file
|
|
195
|
-
saved = @llm_context_unread.merge(@llm_context&.to_file || {})
|
|
196
|
-
saved.empty? ? nil : saved
|
|
322
|
+
def toolkit=(value)
|
|
323
|
+
@toolkit = load_field("toolkit", value)
|
|
197
324
|
end
|
|
198
325
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
# it; a plugin's fork copies it, a delegate child starts without.
|
|
203
|
-
attr_reader :thinking
|
|
326
|
+
def review=(value)
|
|
327
|
+
@review = load_field("review", value)
|
|
328
|
+
end
|
|
204
329
|
|
|
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
|
|
330
|
+
def verify_fresh=(value)
|
|
331
|
+
@verify_fresh = load_field("verify_fresh", value)
|
|
211
332
|
end
|
|
212
333
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
334
|
+
def working_set_pins=(value)
|
|
335
|
+
@working_set_pins = load_field("working_set_pins", value)
|
|
336
|
+
end
|
|
216
337
|
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
attr_reader :reasoning_history
|
|
338
|
+
# Verdict cards recorded by completed Verify turns ([] before the
|
|
339
|
+
# first; the file has none then).
|
|
340
|
+
def verdicts = @verdicts || []
|
|
221
341
|
|
|
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
|
|
342
|
+
def verdicts=(value)
|
|
343
|
+
@verdicts = load_field("verdicts", value)
|
|
228
344
|
end
|
|
229
345
|
|
|
230
|
-
# The session
|
|
231
|
-
#
|
|
232
|
-
|
|
346
|
+
# The session's own levels' and setup's readers and writers: a writer
|
|
347
|
+
# reads its value as its FIELDS row does; thinking, reasoning_history
|
|
348
|
+
# and llm_context keep what of it they couldn't read (SessionField.level).
|
|
349
|
+
attr_reader :llm_context, :thinking, :reasoning_history, :toolkit_origin, :parent_answers
|
|
233
350
|
|
|
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
|
|
351
|
+
def llm_context=(value)
|
|
352
|
+
@llm_context = load_field("llm_context", value)
|
|
353
|
+
end
|
|
242
354
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
@toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
|
|
355
|
+
def thinking=(value)
|
|
356
|
+
@thinking = load_field("thinking", value)
|
|
246
357
|
end
|
|
247
358
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
359
|
+
def reasoning_history=(value)
|
|
360
|
+
@reasoning_history = load_field("reasoning_history", value)
|
|
361
|
+
end
|
|
251
362
|
|
|
252
363
|
def toolkit_origin=(value)
|
|
253
|
-
|
|
254
|
-
|
|
364
|
+
@toolkit_origin = load_field("toolkit_origin", value)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# The user chose the toolkit (/toolkit, the toolkit binding's use): the
|
|
368
|
+
# origin is chosen. Whether that changes what the file holds, so the
|
|
369
|
+
# caller saves only then: a kept unknown origin (the Unread rule) reads
|
|
370
|
+
# as chosen but is written as it was, so it counts as a change.
|
|
371
|
+
# @return [Boolean]
|
|
372
|
+
def choose_toolkit_origin!
|
|
373
|
+
field = FIELDS.fetch("toolkit_origin")
|
|
374
|
+
was = field.dump(@toolkit_origin, @unread["toolkit_origin"])
|
|
375
|
+
self.toolkit_origin = "chosen"
|
|
376
|
+
was != field.dump(@toolkit_origin, @unread["toolkit_origin"])
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def parent_answers=(value)
|
|
380
|
+
@parent_answers = load_field("parent_answers", value)
|
|
255
381
|
end
|
|
256
382
|
|
|
383
|
+
# The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
|
|
384
|
+
# (marker an InterruptedTurn). While +status+ is running, every save
|
|
385
|
+
# writes these messages and the marker instead of #messages and
|
|
386
|
+
# #interrupted_turn, so a save in the middle of the turn (a question
|
|
387
|
+
# asked, the checkpoint's own) keeps the turn so far on disk; #messages
|
|
388
|
+
# itself is never touched. The turn's end sets status idle before it
|
|
389
|
+
# saves, so that save writes the real messages, without a marker. Not
|
|
390
|
+
# saved; the worker clears it after each turn.
|
|
391
|
+
attr_accessor :turn_checkpoint
|
|
257
392
|
# The session's own settings (chi --set), a SessionConfig::Settings:
|
|
258
393
|
# empty for none and for a session from before them. Its core part
|
|
259
394
|
# ({key => value}: recap.enabled) is the file's config_settings, its
|
|
@@ -269,48 +404,9 @@ module Samagotchi
|
|
|
269
404
|
@settings = SessionConfig::Settings.of(value)
|
|
270
405
|
end
|
|
271
406
|
|
|
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
407
|
# Whether this is a review session (chi review, the web's Start review).
|
|
303
408
|
def review? = !@review.nil?
|
|
304
409
|
|
|
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
410
|
# The session whose participants.json names this session's
|
|
315
411
|
# participants: a Verify fresh child's parent (its requester's grant is
|
|
316
412
|
# the parent's), else the session itself.
|
|
@@ -323,17 +419,6 @@ module Samagotchi
|
|
|
323
419
|
|
|
324
420
|
TOOL_ROLES = %w[tool_response tool].freeze
|
|
325
421
|
|
|
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
422
|
# The hook that stopped the last turn (stop_turn, or a cut with no retry
|
|
338
423
|
# left), e.g. "loop-guard"; nil when it ended otherwise.
|
|
339
424
|
def stopped_by
|
|
@@ -370,59 +455,18 @@ module Samagotchi
|
|
|
370
455
|
# FIELDS as they were. Never read by this chi.
|
|
371
456
|
attr_reader :extra_fields
|
|
372
457
|
|
|
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
|
|
458
|
+
# Every FIELDS row's attr is a keyword (a missing required one or an
|
|
459
|
+
# unknown one raises ArgumentError, as Ruby's keywords do), given as is
|
|
460
|
+
# (cast by its row, as the writers cast); +settings+ is the
|
|
461
|
+
# settings pair's one value, +extra_fields+ the file's unknown keys.
|
|
462
|
+
def initialize(extra_fields: {}, settings: nil, **attrs)
|
|
463
|
+
check_attrs!(attrs)
|
|
464
|
+
# What the level fields couldn't read, by key (SessionField.level).
|
|
465
|
+
@unread = {}
|
|
466
|
+
FIELDS.each_value do |field|
|
|
467
|
+
instance_variable_set(field.ivar, load_field(field.key, attrs.fetch(field.attr) { field.fresh_value })) if field.attr
|
|
468
|
+
end
|
|
419
469
|
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
470
|
@extra_fields = extra_fields.to_h { |key, value| [key.to_s, value] }.freeze
|
|
427
471
|
@turn_checkpoint = nil
|
|
428
472
|
@archived = false
|
|
@@ -515,16 +559,20 @@ module Samagotchi
|
|
|
515
559
|
# Build a new, unsaved session.
|
|
516
560
|
# @param messages [Array<Hash>] a conversation to start from (a fork's
|
|
517
561
|
# seed); [] by default
|
|
518
|
-
# @param setup [SessionSetup] the session's own settings (none by
|
|
562
|
+
# @param setup [SessionSetup, nil] the session's own settings (none by
|
|
563
|
+
# default): each member is the field of the same name (a member that
|
|
564
|
+
# isn't one is an ArgumentError), a toolkit_origin of none is chosen
|
|
519
565
|
# @param now [Time] its created_at and updated_at (a spec's clock)
|
|
520
566
|
# @raise [Toolkit::Refused] its toolkit (setup's, else the configured
|
|
521
567
|
# one SessionManager.spawn_session gives it) builds on a config kit
|
|
522
568
|
# with an invalid groups: (Toolkit.check_new!)
|
|
523
569
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
524
570
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
525
|
-
scratch: false, model_typed: nil, delegate: false, setup:
|
|
571
|
+
scratch: false, model_typed: nil, delegate: false, setup: nil, continues: nil,
|
|
526
572
|
review: nil, now: Time.now)
|
|
527
|
-
|
|
573
|
+
own = setup.to_h
|
|
574
|
+
own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
|
|
575
|
+
Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
|
|
528
576
|
now = now.iso8601(3)
|
|
529
577
|
resolved_test = if test_run.nil?
|
|
530
578
|
test_session_env?
|
|
@@ -549,17 +597,9 @@ module Samagotchi
|
|
|
549
597
|
parent_id: parent_id,
|
|
550
598
|
scratch: scratch,
|
|
551
599
|
delegate: delegate,
|
|
552
|
-
llm_context: setup.llm_context,
|
|
553
|
-
thinking: setup.thinking,
|
|
554
|
-
reasoning_history: setup.reasoning_history,
|
|
555
|
-
appended_prompt_files: setup.appended_prompt_files,
|
|
556
|
-
toolkit: setup.toolkit,
|
|
557
|
-
toolkit_origin: setup.toolkit_origin || "chosen",
|
|
558
|
-
settings: setup.settings,
|
|
559
|
-
parent_answers: setup.parent_answers,
|
|
560
|
-
working_set_pins: setup.working_set_pins,
|
|
561
600
|
continues: continues,
|
|
562
|
-
review: review
|
|
601
|
+
review: review,
|
|
602
|
+
**own
|
|
563
603
|
)
|
|
564
604
|
end
|
|
565
605
|
|
|
@@ -588,84 +628,12 @@ module Samagotchi
|
|
|
588
628
|
raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
|
|
589
629
|
end
|
|
590
630
|
|
|
591
|
-
# A field a session file must have (KeyError without it).
|
|
592
|
-
REQUIRED = Object.new.freeze
|
|
593
|
-
|
|
594
|
-
# The fields of <id>.json, in the order #save writes them: key => the
|
|
595
|
-
# value for a file without it (REQUIRED for the ones it must have).
|
|
596
|
-
# .from_h and #to_h both go by this table; messages change key type on
|
|
597
|
-
# the way (symbols in memory, strings on disk); last_turn, prompt_notes
|
|
598
|
-
# and pending_question are value objects in memory (LastTurn,
|
|
599
|
-
# PromptNote, PendingQuestion).
|
|
600
|
-
#
|
|
601
|
-
# A new field: a chi older than it (from the release after 0.130.0)
|
|
602
|
-
# keeps the key as it was (#extra_fields) but doesn't honour it, and the
|
|
603
|
-
# next newer chi reads it again. So it must be safe to come back
|
|
604
|
-
# unchanged after an older chi ran turns on the session: static data, or
|
|
605
|
-
# append-only data the older chi never appends to (review, verify_fresh,
|
|
606
|
-
# first_turn_id, verdicts); a cursor into messages, which an older chi
|
|
607
|
-
# would have moved, needs a check against what it points at. Only
|
|
608
|
-
# top-level keys are kept: the value objects (SessionReview,
|
|
609
|
-
# VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
|
|
610
|
-
# PromptNote, WorkingSetPin, LLMContextOverride) write their own members
|
|
611
|
-
# only, so an older chi still drops a new sub-key of one of them; add a
|
|
612
|
-
# top-level field when an older chi must keep it. A removed key would
|
|
613
|
-
# stay an extra forever: leave it out of the extras in .from_h then (no
|
|
614
|
-
# key was ever removed).
|
|
615
|
-
FIELDS = {
|
|
616
|
-
"metadata_version" => 1,
|
|
617
|
-
"written_by" => nil,
|
|
618
|
-
"id" => REQUIRED,
|
|
619
|
-
"mode" => REQUIRED,
|
|
620
|
-
"model_name" => REQUIRED,
|
|
621
|
-
"model_typed" => nil,
|
|
622
|
-
"working_directory" => REQUIRED,
|
|
623
|
-
"messages" => [],
|
|
624
|
-
"created_at" => REQUIRED,
|
|
625
|
-
"updated_at" => REQUIRED,
|
|
626
|
-
"status" => STATUS_IDLE,
|
|
627
|
-
"last_prompt" => "",
|
|
628
|
-
"first_preview" => "",
|
|
629
|
-
"title" => nil,
|
|
630
|
-
"test_run" => false,
|
|
631
|
-
"pending_question" => nil,
|
|
632
|
-
"used_memory_names" => [],
|
|
633
|
-
"project_root" => nil,
|
|
634
|
-
"preloaded_memory_names" => [],
|
|
635
|
-
"muted_memory_names" => [],
|
|
636
|
-
"prompt_notes" => [],
|
|
637
|
-
"parent_id" => nil,
|
|
638
|
-
"scratch" => false,
|
|
639
|
-
"last_turn" => nil,
|
|
640
|
-
"verdicts" => nil,
|
|
641
|
-
"delegate" => false,
|
|
642
|
-
"llm_context" => nil,
|
|
643
|
-
"continues" => nil,
|
|
644
|
-
"thinking" => nil,
|
|
645
|
-
"reasoning_history" => nil,
|
|
646
|
-
"appended_prompt_files" => [],
|
|
647
|
-
"toolkit" => nil,
|
|
648
|
-
"toolkit_origin" => "chosen",
|
|
649
|
-
"bundle_settings" => {},
|
|
650
|
-
"config_settings" => {},
|
|
651
|
-
"interrupted_turn" => nil,
|
|
652
|
-
"parent_answers" => nil,
|
|
653
|
-
"working_set_pins" => [],
|
|
654
|
-
"review" => nil,
|
|
655
|
-
"first_turn_id" => nil,
|
|
656
|
-
"verify_fresh" => nil
|
|
657
|
-
}.freeze
|
|
658
|
-
|
|
659
|
-
# FIELDS a session file has only when set: a session without them saves
|
|
660
|
-
# as it did before they existed.
|
|
661
|
-
OPTIONAL_FIELDS = %w[review first_turn_id verdicts verify_fresh].freeze
|
|
662
|
-
|
|
663
631
|
# A session from a parsed session file. +messages+ false leaves the
|
|
664
632
|
# conversation out (the lists' lightweight summaries).
|
|
665
633
|
# @raise [KeyError] for a missing REQUIRED field
|
|
666
634
|
def self.from_h(data, messages: true)
|
|
667
|
-
attrs = FIELDS.to_h do |key,
|
|
668
|
-
[key.to_sym,
|
|
635
|
+
attrs = FIELDS.to_h do |key, field|
|
|
636
|
+
[key.to_sym, field.required? ? data.fetch(key) : data.fetch(key, field.default)]
|
|
669
637
|
end
|
|
670
638
|
attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
|
|
671
639
|
attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
|
|
@@ -717,7 +685,8 @@ module Samagotchi
|
|
|
717
685
|
id = id_or_prefix.to_s
|
|
718
686
|
return id unless valid_id?(id) && !File.exist?(session_path(id, state_dir: state_dir))
|
|
719
687
|
|
|
720
|
-
matches = Dir.glob(File.join(state_dir, "#{id}*#{FILE_EXT}"))
|
|
688
|
+
matches = Dir.glob(File.join(state_dir, "#{id}*#{SessionPaths::FILE_EXT}"))
|
|
689
|
+
.map { |path| File.basename(path, SessionPaths::FILE_EXT) }.sort
|
|
721
690
|
return matches.fetch(0, id) if matches.size <= 1
|
|
722
691
|
|
|
723
692
|
lines = matches.map do |match|
|
|
@@ -743,7 +712,7 @@ module Samagotchi
|
|
|
743
712
|
sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
|
|
744
713
|
sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
|
|
745
714
|
|
|
746
|
-
sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
|
|
715
|
+
sessions = Dir.glob(File.join(state_dir, "*#{SessionPaths::FILE_EXT}")).filter_map { |path| summary_from_file(path) }
|
|
747
716
|
sessions.each { |s| s.archived = ArchiveStore.archived?(session_dir(s.id, state_dir: state_dir)) }
|
|
748
717
|
sessions.reject!(&:archived) unless include_archived
|
|
749
718
|
if project_root
|
|
@@ -823,40 +792,35 @@ module Samagotchi
|
|
|
823
792
|
# messages.
|
|
824
793
|
def to_h
|
|
825
794
|
checkpoint = checkpointed
|
|
826
|
-
FIELDS.
|
|
827
|
-
value = case key
|
|
828
|
-
when "messages"
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
|
|
832
|
-
when "written_by" then VERSION
|
|
833
|
-
when "test_run" then !!@test_run
|
|
834
|
-
when "delegate" then @delegate
|
|
835
|
-
when "llm_context" then llm_context_file
|
|
836
|
-
when "thinking" then thinking_file
|
|
837
|
-
when "reasoning_history" then reasoning_history_file
|
|
838
|
-
when "toolkit" then @toolkit&.to_file
|
|
839
|
-
when "bundle_settings" then @settings.bundles
|
|
840
|
-
when "config_settings" then @settings.core
|
|
841
|
-
when "prompt_notes" then @prompt_notes.map(&:to_file)
|
|
842
|
-
when "last_turn" then @last_turn&.to_file
|
|
843
|
-
when "working_set_pins" then @working_set_pins.map(&:to_file)
|
|
844
|
-
when "review" then @review&.to_file
|
|
845
|
-
when "verify_fresh" then @verify_fresh&.to_file
|
|
846
|
-
when "verdicts"
|
|
847
|
-
@verdicts&.map do |record|
|
|
848
|
-
scrub_utf8(record.to_h do |key, entry|
|
|
849
|
-
[key.to_s, entry.is_a?(Hash) ? stringify_message_keys(entry) : entry]
|
|
850
|
-
end)
|
|
851
|
-
end
|
|
852
|
-
when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
|
|
853
|
-
Array(instance_variable_get(:"@#{key}"))
|
|
854
|
-
else instance_variable_get(:"@#{key}")
|
|
795
|
+
FIELDS.each_value.with_object({}) do |field, file|
|
|
796
|
+
value = case field.key
|
|
797
|
+
when "messages" then field.dump(checkpoint ? checkpoint[:messages] : @messages)
|
|
798
|
+
when "interrupted_turn" then field.dump(checkpoint ? checkpoint[:marker] : @interrupted_turn)
|
|
799
|
+
else field.dump(instance_variable_get(field.ivar), @unread[field.key])
|
|
855
800
|
end
|
|
856
|
-
[key
|
|
857
|
-
end.reject { |key,
|
|
858
|
-
|
|
801
|
+
file[field.key] = value unless value.nil? && field.optional
|
|
802
|
+
end.merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# +value+ as +key+'s FIELDS row reads it (its writer's value); what of
|
|
806
|
+
# it a level field couldn't read is kept for #to_h.
|
|
807
|
+
def load_field(key, value)
|
|
808
|
+
field = FIELDS.fetch(key)
|
|
809
|
+
@unread[key] = field.unread.call(value) if field.unread
|
|
810
|
+
field.cast_value(value)
|
|
811
|
+
end
|
|
812
|
+
private :load_field
|
|
813
|
+
|
|
814
|
+
# @raise [ArgumentError] a keyword Session.new doesn't take, or a
|
|
815
|
+
# required one missing (Ruby's own wording)
|
|
816
|
+
def check_attrs!(attrs)
|
|
817
|
+
unknown = attrs.keys - ATTRS
|
|
818
|
+
raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
|
|
819
|
+
|
|
820
|
+
missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
|
|
821
|
+
raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
|
|
859
822
|
end
|
|
823
|
+
private :check_attrs!
|
|
860
824
|
|
|
861
825
|
# The running turn's checkpoint, while the turn runs (#turn_checkpoint).
|
|
862
826
|
def checkpointed
|
|
@@ -916,45 +880,6 @@ module Samagotchi
|
|
|
916
880
|
nil
|
|
917
881
|
end
|
|
918
882
|
|
|
919
|
-
# An id that is not a session id: it could name a path outside the
|
|
920
|
-
# sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
|
|
921
|
-
class InvalidId < ArgumentError; end
|
|
922
|
-
|
|
923
|
-
# Ids are SecureRandom.uuid; anything made of letters, digits, "-" and
|
|
924
|
-
# "_" (up to 128, not starting with "-") is taken, so a UUID prefix the
|
|
925
|
-
# CLI resolves passes too. This is the one check every id from outside
|
|
926
|
-
# (the bridge, the web routes, chi send/note, delegate) meets before it
|
|
927
|
-
# becomes a path: session_dir and the session file path refuse others.
|
|
928
|
-
VALID_ID = /\A[A-Za-z0-9_][A-Za-z0-9_-]{0,127}\z/
|
|
929
|
-
|
|
930
|
-
def self.valid_id?(session_id)
|
|
931
|
-
session_id.is_a?(String) && VALID_ID.match?(session_id)
|
|
932
|
-
end
|
|
933
|
-
|
|
934
|
-
# @raise [InvalidId] unless valid_id?(session_id)
|
|
935
|
-
def self.check_id!(session_id)
|
|
936
|
-
return session_id if valid_id?(session_id)
|
|
937
|
-
|
|
938
|
-
raise InvalidId, "invalid session id: #{session_id.to_s[0, 80].inspect}"
|
|
939
|
-
end
|
|
940
|
-
|
|
941
|
-
# Directory for a specific session (holds IPC files alongside session.json).
|
|
942
|
-
# @raise [InvalidId] for an id that isn't one (valid_id?)
|
|
943
|
-
def self.session_dir(session_id, state_dir: default_state_dir)
|
|
944
|
-
File.join(state_dir, check_id!(session_id))
|
|
945
|
-
end
|
|
946
|
-
|
|
947
|
-
# A session's <id>.json.
|
|
948
|
-
# @raise [InvalidId] for an id that isn't one (valid_id?)
|
|
949
|
-
def self.session_file(session_id, state_dir: default_state_dir)
|
|
950
|
-
File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
|
|
951
|
-
end
|
|
952
|
-
|
|
953
|
-
# XDG-aware sessions directory.
|
|
954
|
-
def self.default_state_dir(env: ENV)
|
|
955
|
-
File.join(Paths.state_home(env: env), STATE_SUBDIR)
|
|
956
|
-
end
|
|
957
|
-
|
|
958
883
|
# What names the session: its #title when it has one, else its
|
|
959
884
|
# #first_preview.
|
|
960
885
|
def display_title
|
|
@@ -984,8 +909,9 @@ module Samagotchi
|
|
|
984
909
|
# A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
|
|
985
910
|
# A quoted or annotated message previews by what was typed under the
|
|
986
911
|
# quote; a quote alone by its words, without the "> " markers or label.
|
|
912
|
+
# Bytes that aren't UTF-8 preview as ?, as the file saves them.
|
|
987
913
|
def self.preview_of(text)
|
|
988
|
-
norm = preview_words(text.to_s).gsub(/\s+/, " ").strip
|
|
914
|
+
norm = preview_words(SessionField.scrub_utf8(text.to_s)).gsub(/\s+/, " ").strip
|
|
989
915
|
norm.length > 80 ? "#{norm[0, 80]}…" : norm
|
|
990
916
|
end
|
|
991
917
|
|
|
@@ -1013,24 +939,6 @@ module Samagotchi
|
|
|
1013
939
|
text.empty? ? nil : self.class.preview_of("note: #{text}")
|
|
1014
940
|
end
|
|
1015
941
|
|
|
1016
|
-
def stringify_message_keys(hash)
|
|
1017
|
-
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
1018
|
-
end
|
|
1019
|
-
|
|
1020
|
-
# The last guard before JSON.generate, which raises on bytes that
|
|
1021
|
-
# aren't UTF-8: a save that raised would lose the whole conversation.
|
|
1022
|
-
# ToolRunner already scrubs tool output; this covers any other source.
|
|
1023
|
-
def scrub_utf8(obj)
|
|
1024
|
-
case obj
|
|
1025
|
-
when String
|
|
1026
|
-
str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
|
|
1027
|
-
str.valid_encoding? ? str : str.scrub("?")
|
|
1028
|
-
when Array then obj.map { |element| scrub_utf8(element) }
|
|
1029
|
-
when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
|
|
1030
|
-
else obj
|
|
1031
|
-
end
|
|
1032
|
-
end
|
|
1033
|
-
|
|
1034
942
|
class << self
|
|
1035
943
|
private
|
|
1036
944
|
|