samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "project_scope"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
class Session
|
|
12
|
+
METADATA_VERSION = 3
|
|
13
|
+
STATE_SUBDIR = File.join("samagotchi", "sessions")
|
|
14
|
+
FILE_EXT = ".json"
|
|
15
|
+
|
|
16
|
+
STATUS_IDLE = "idle"
|
|
17
|
+
STATUS_RUNNING = "running"
|
|
18
|
+
STATUS_COMPLETED = "completed"
|
|
19
|
+
STATUS_ERROR = "error"
|
|
20
|
+
STATUS_STOPPED = "stopped"
|
|
21
|
+
|
|
22
|
+
# Retention / ordering defaults (14 days, 500 sessions) — env overrides in SessionManager.
|
|
23
|
+
DEFAULT_RETENTION_DAYS = 14
|
|
24
|
+
DEFAULT_MAX_COUNT = 500
|
|
25
|
+
# status is turn state; a live owner (the prune alive_check) is what
|
|
26
|
+
# protects a session in use, so no status is kept by default.
|
|
27
|
+
DEFAULT_KEEP_STATUS = [].freeze
|
|
28
|
+
SORT_KEYS = %w[created_at updated_at].freeze
|
|
29
|
+
SORT_ORDERS = %w[asc desc].freeze
|
|
30
|
+
|
|
31
|
+
attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
|
|
32
|
+
:created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
|
|
33
|
+
:pending_question, :used_memory_names
|
|
34
|
+
# Memories the session was started with (--memory) and memories hidden
|
|
35
|
+
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
36
|
+
# Names as given; the engine normalizes them.
|
|
37
|
+
attr_accessor :preloaded_memory_names, :muted_memory_names
|
|
38
|
+
# The project the session was started in (ProjectScope.root_for its
|
|
39
|
+
# folder), stored because worktrees are deleted after a merge and a
|
|
40
|
+
# deleted folder no longer leads to its repository. nil outside a repo,
|
|
41
|
+
# and in files written before the field existed (see #project_root).
|
|
42
|
+
attr_writer :project_root
|
|
43
|
+
# The session that delegated this one (the `delegate` tool) or that a
|
|
44
|
+
# plugin forked it from (ctx.sessions.fork), else nil.
|
|
45
|
+
# Set before the spawn and kept on respawns, like preloaded_memory_names.
|
|
46
|
+
attr_accessor :parent_id
|
|
47
|
+
|
|
48
|
+
# How many image refs a fork's seed lost (SessionManager.spawn_session
|
|
49
|
+
# sets it); not saved.
|
|
50
|
+
attr_accessor :seed_images_dropped
|
|
51
|
+
|
|
52
|
+
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
53
|
+
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
54
|
+
first_preview: "", test_run: false, pending_question: nil,
|
|
55
|
+
used_memory_names: [], project_root: nil,
|
|
56
|
+
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil)
|
|
57
|
+
@id = id
|
|
58
|
+
@metadata_version = metadata_version
|
|
59
|
+
@mode = mode
|
|
60
|
+
@model_name = model_name
|
|
61
|
+
@working_directory = working_directory
|
|
62
|
+
@messages = messages
|
|
63
|
+
@created_at = created_at
|
|
64
|
+
@updated_at = updated_at
|
|
65
|
+
@status = status
|
|
66
|
+
@last_prompt = last_prompt
|
|
67
|
+
@first_preview = first_preview
|
|
68
|
+
@test_run = !!test_run
|
|
69
|
+
@pending_question = pending_question
|
|
70
|
+
@used_memory_names = self.class.name_list(used_memory_names)
|
|
71
|
+
@preloaded_memory_names = self.class.name_list(preloaded_memory_names)
|
|
72
|
+
@muted_memory_names = self.class.name_list(muted_memory_names)
|
|
73
|
+
@project_root = project_root
|
|
74
|
+
@parent_id = parent_id&.to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The stored project root, else (a file from before the field) the
|
|
78
|
+
# project of the working directory now: nil when that is in no repo or
|
|
79
|
+
# gone. +cache+ is ProjectScope.root_for's, shared across one listing.
|
|
80
|
+
def project_root(cache: nil)
|
|
81
|
+
return @project_root if @project_root
|
|
82
|
+
|
|
83
|
+
ProjectScope.root_for(@working_directory, cache: cache)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A list of memory names: strings, stripped, no blanks, no repeats.
|
|
87
|
+
def self.name_list(names)
|
|
88
|
+
Array(names).map { |n| n.to_s.strip }.reject(&:empty?).uniq
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build a new, unsaved session.
|
|
92
|
+
# @param messages [Array<Hash>] a conversation to start from (a fork's
|
|
93
|
+
# seed); [] by default
|
|
94
|
+
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
95
|
+
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [])
|
|
96
|
+
now = Time.now.iso8601(3)
|
|
97
|
+
resolved_test = if test_run.nil?
|
|
98
|
+
test_session_env?
|
|
99
|
+
else
|
|
100
|
+
!!test_run
|
|
101
|
+
end
|
|
102
|
+
new(
|
|
103
|
+
id: SecureRandom.uuid,
|
|
104
|
+
mode: mode.to_s,
|
|
105
|
+
model_name: model_name.to_s,
|
|
106
|
+
working_directory: working_directory.to_s,
|
|
107
|
+
messages: Array(messages).map(&:dup),
|
|
108
|
+
created_at: now,
|
|
109
|
+
updated_at: now,
|
|
110
|
+
status: STATUS_IDLE,
|
|
111
|
+
first_preview: "",
|
|
112
|
+
test_run: resolved_test,
|
|
113
|
+
project_root: ProjectScope.root_for(working_directory),
|
|
114
|
+
preloaded_memory_names: preloaded_memory_names,
|
|
115
|
+
muted_memory_names: muted_memory_names,
|
|
116
|
+
parent_id: parent_id
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.test_session_env?(env: ENV)
|
|
121
|
+
env["SAMAGOTCHI_ENV"].to_s == "test" || env["RACK_ENV"].to_s == "test" || !env["CI"].to_s.strip.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Load a persisted session by its UUID.
|
|
125
|
+
# Whether +session_id+ has a saved session.
|
|
126
|
+
def self.exist?(session_id, state_dir: default_state_dir)
|
|
127
|
+
File.exist?(session_path(session_id, state_dir: state_dir))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.load(session_id, state_dir: default_state_dir)
|
|
131
|
+
path = session_path(session_id, state_dir: state_dir)
|
|
132
|
+
raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(path)
|
|
133
|
+
|
|
134
|
+
data = JSON.parse(File.read(path))
|
|
135
|
+
messages = (data["messages"] || []).map { |msg| symbolize_message_keys(msg) }
|
|
136
|
+
pending = data["pending_question"]
|
|
137
|
+
pending = symbolize_message_keys(pending) if pending.is_a?(Hash)
|
|
138
|
+
used_mems = data["used_memory_names"] || data["used_memories"] || []
|
|
139
|
+
new(
|
|
140
|
+
id: data.fetch("id"),
|
|
141
|
+
metadata_version: data.fetch("metadata_version", 1),
|
|
142
|
+
mode: data.fetch("mode"),
|
|
143
|
+
model_name: data.fetch("model_name"),
|
|
144
|
+
working_directory: data.fetch("working_directory"),
|
|
145
|
+
messages: messages,
|
|
146
|
+
created_at: data.fetch("created_at"),
|
|
147
|
+
updated_at: data.fetch("updated_at"),
|
|
148
|
+
status: data.fetch("status", STATUS_IDLE),
|
|
149
|
+
last_prompt: data.fetch("last_prompt", ""),
|
|
150
|
+
first_preview: data.fetch("first_preview", ""),
|
|
151
|
+
test_run: data.fetch("test_run", false),
|
|
152
|
+
pending_question: pending,
|
|
153
|
+
used_memory_names: Array(used_mems),
|
|
154
|
+
project_root: data["project_root"],
|
|
155
|
+
preloaded_memory_names: Array(data["preloaded_memory_names"]),
|
|
156
|
+
muted_memory_names: Array(data["muted_memory_names"]),
|
|
157
|
+
parent_id: data["parent_id"]
|
|
158
|
+
)
|
|
159
|
+
rescue JSON::ParserError => e
|
|
160
|
+
raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A prefix that names more than one session.
|
|
164
|
+
class AmbiguousId < ArgumentError; end
|
|
165
|
+
|
|
166
|
+
# A session id or a unique prefix of one (like git's): the full id. An
|
|
167
|
+
# unknown one comes back as is, for the caller's own "not found"; one that
|
|
168
|
+
# names several sessions raises AmbiguousId, which lists them.
|
|
169
|
+
def self.resolve_id(id_or_prefix, state_dir: default_state_dir)
|
|
170
|
+
id = id_or_prefix.to_s
|
|
171
|
+
return id unless id.match?(/\A[\w-]+\z/) && !File.exist?(session_path(id, state_dir: state_dir))
|
|
172
|
+
|
|
173
|
+
matches = Dir.glob(File.join(state_dir, "#{id}*#{FILE_EXT}")).map { |path| File.basename(path, FILE_EXT) }.sort
|
|
174
|
+
return matches.fetch(0, id) if matches.size <= 1
|
|
175
|
+
|
|
176
|
+
lines = matches.map do |match|
|
|
177
|
+
preview = JSON.parse(File.read(session_path(match, state_dir: state_dir)))["first_preview"].to_s
|
|
178
|
+
" #{match} #{preview}".rstrip
|
|
179
|
+
rescue JSON::ParserError, SystemCallError
|
|
180
|
+
" #{match}"
|
|
181
|
+
end
|
|
182
|
+
raise AmbiguousId, "session id #{id} matches #{matches.size} sessions:\n#{lines.join("\n")}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Return all saved sessions sorted by updated_at desc by default (newest first).
|
|
186
|
+
# Supports sort: created_at|updated_at and order: asc|desc.
|
|
187
|
+
# +project_root+ keeps only that project's sessions (Session#project_root),
|
|
188
|
+
# before offset/limit so pages count within the project.
|
|
189
|
+
def self.list(state_dir: default_state_dir, sort: "updated_at", order: "desc", limit: nil, offset: 0,
|
|
190
|
+
project_root: nil)
|
|
191
|
+
return [] unless Dir.exist?(state_dir)
|
|
192
|
+
|
|
193
|
+
sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
|
|
194
|
+
sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
|
|
195
|
+
|
|
196
|
+
sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
|
|
197
|
+
if project_root
|
|
198
|
+
roots = {}
|
|
199
|
+
sessions.select! { |s| s.project_root(cache: roots) == project_root }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
sorted = sessions.sort_by do |s|
|
|
203
|
+
val = sort_key == "created_at" ? s.created_at : s.updated_at
|
|
204
|
+
begin
|
|
205
|
+
Time.iso8601(val.to_s)
|
|
206
|
+
rescue ArgumentError
|
|
207
|
+
Time.at(0)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
sorted.reverse! if sort_order == "desc"
|
|
211
|
+
# Apply offset/limit if given
|
|
212
|
+
off = offset.to_i
|
|
213
|
+
sorted = sorted.drop(off) if off.positive?
|
|
214
|
+
if limit && limit.to_i.positive?
|
|
215
|
+
sorted = sorted.first(limit.to_i)
|
|
216
|
+
end
|
|
217
|
+
sorted
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# One session file as .list reads it: the session without its messages
|
|
221
|
+
# (the list is lightweight). The session hub reads files the same way,
|
|
222
|
+
# so both agree on which files count.
|
|
223
|
+
# @return [Session, nil] nil for a file that is corrupt, missing a
|
|
224
|
+
# required field, or gone
|
|
225
|
+
def self.summary_from_file(path)
|
|
226
|
+
data = JSON.parse(File.read(path))
|
|
227
|
+
used_mems = data["used_memory_names"] || data["used_memories"] || []
|
|
228
|
+
new(
|
|
229
|
+
id: data.fetch("id"),
|
|
230
|
+
metadata_version: data.fetch("metadata_version", 1),
|
|
231
|
+
mode: data.fetch("mode"),
|
|
232
|
+
model_name: data.fetch("model_name"),
|
|
233
|
+
working_directory: data.fetch("working_directory"),
|
|
234
|
+
messages: [],
|
|
235
|
+
created_at: data.fetch("created_at"),
|
|
236
|
+
updated_at: data.fetch("updated_at"),
|
|
237
|
+
status: data.fetch("status", STATUS_IDLE),
|
|
238
|
+
last_prompt: data.fetch("last_prompt", ""),
|
|
239
|
+
first_preview: data.fetch("first_preview", ""),
|
|
240
|
+
test_run: data.fetch("test_run", false),
|
|
241
|
+
used_memory_names: Array(used_mems),
|
|
242
|
+
project_root: data["project_root"],
|
|
243
|
+
preloaded_memory_names: Array(data["preloaded_memory_names"]),
|
|
244
|
+
muted_memory_names: Array(data["muted_memory_names"]),
|
|
245
|
+
parent_id: data["parent_id"]
|
|
246
|
+
)
|
|
247
|
+
rescue JSON::ParserError, KeyError, SystemCallError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Prune old sessions according to retention policy.
|
|
252
|
+
#
|
|
253
|
+
# Only deletes when the json file exists — orphan dirs without json are never removed.
|
|
254
|
+
# Honors keep_status and live-worker guard.
|
|
255
|
+
# A session is kept only if it is NOT expired by age AND within max_count;
|
|
256
|
+
# either expiry or overflow triggers deletion (unless protected).
|
|
257
|
+
#
|
|
258
|
+
# +empty_check+ (id → Boolean) marks a session left empty: it goes
|
|
259
|
+
# whatever its age and the count (SessionManager.prune_sessions).
|
|
260
|
+
#
|
|
261
|
+
# +any_age+ makes every session eligible, whatever its age and the
|
|
262
|
+
# count (`chi sessions clean` with no --days: test runs are throwaway).
|
|
263
|
+
#
|
|
264
|
+
# @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
|
|
265
|
+
def self.prune(state_dir: default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
|
|
266
|
+
keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
|
|
267
|
+
empty_check: nil, any_age: false)
|
|
268
|
+
keep_status = Array(keep_status).map(&:to_s)
|
|
269
|
+
# Fetch all sessions sorted newest-first for count logic
|
|
270
|
+
all = list(state_dir: state_dir, sort: "updated_at", order: "desc")
|
|
271
|
+
# Filter test_only if requested
|
|
272
|
+
if test_only
|
|
273
|
+
all = all.select(&:test_run)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
now = Time.now
|
|
277
|
+
cutoff = days.to_i.positive? ? now - days.to_i * 86_400 : nil
|
|
278
|
+
max = max_count.to_i
|
|
279
|
+
|
|
280
|
+
deleted = []
|
|
281
|
+
kept = []
|
|
282
|
+
skipped = []
|
|
283
|
+
|
|
284
|
+
all.each_with_index do |session, idx|
|
|
285
|
+
path = File.join(state_dir, "#{session.id}#{FILE_EXT}")
|
|
286
|
+
# Only when json present
|
|
287
|
+
unless File.exist?(path)
|
|
288
|
+
skipped << session.id
|
|
289
|
+
next
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Protected by keep_status
|
|
293
|
+
if keep_status.include?(session.status.to_s)
|
|
294
|
+
kept << session.id
|
|
295
|
+
next
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Protected by live worker
|
|
299
|
+
if alive_check
|
|
300
|
+
begin
|
|
301
|
+
if alive_check.call(session.id)
|
|
302
|
+
kept << session.id
|
|
303
|
+
next
|
|
304
|
+
end
|
|
305
|
+
rescue StandardError
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
left_empty = begin
|
|
311
|
+
empty_check&.call(session.id)
|
|
312
|
+
rescue StandardError
|
|
313
|
+
false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Determine expiry and overflow
|
|
317
|
+
expired = false
|
|
318
|
+
if cutoff
|
|
319
|
+
begin
|
|
320
|
+
updated = Time.iso8601(session.updated_at.to_s)
|
|
321
|
+
rescue ArgumentError
|
|
322
|
+
updated = File.mtime(path) rescue now
|
|
323
|
+
end
|
|
324
|
+
expired = updated < cutoff
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
overflow = max.positive? && idx >= max
|
|
328
|
+
|
|
329
|
+
# retain forever when both disabled
|
|
330
|
+
if max.zero? && cutoff.nil? && !left_empty && !any_age
|
|
331
|
+
kept << session.id
|
|
332
|
+
next
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# If neither expired nor overflow, keep
|
|
336
|
+
unless expired || overflow || left_empty || any_age
|
|
337
|
+
kept << session.id
|
|
338
|
+
next
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Eligible for deletion
|
|
342
|
+
if dry_run
|
|
343
|
+
deleted << session.id
|
|
344
|
+
else
|
|
345
|
+
begin
|
|
346
|
+
FileUtils.rm_f(path)
|
|
347
|
+
sidecar = File.join(state_dir, session.id)
|
|
348
|
+
FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
|
|
349
|
+
deleted << session.id
|
|
350
|
+
rescue StandardError
|
|
351
|
+
skipped << session.id
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
{ deleted: deleted, kept: kept, skipped: skipped }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Persist the session atomically. Updates +updated_at+ in place.
|
|
360
|
+
def save(state_dir: self.class.default_state_dir)
|
|
361
|
+
@updated_at = Time.now.iso8601(3)
|
|
362
|
+
# Persist with current metadata version so new flag is written
|
|
363
|
+
@metadata_version = METADATA_VERSION
|
|
364
|
+
FileUtils.mkdir_p(state_dir)
|
|
365
|
+
|
|
366
|
+
path = File.join(state_dir, "#{@id}#{FILE_EXT}")
|
|
367
|
+
temp_path = "#{path}.tmp"
|
|
368
|
+
|
|
369
|
+
# Auto-compute first_preview if not yet cached and messages contain a user entry.
|
|
370
|
+
compute_first_preview!
|
|
371
|
+
|
|
372
|
+
record = {
|
|
373
|
+
"metadata_version" => @metadata_version,
|
|
374
|
+
"id" => @id,
|
|
375
|
+
"mode" => @mode,
|
|
376
|
+
"model_name" => @model_name,
|
|
377
|
+
"working_directory" => @working_directory,
|
|
378
|
+
"messages" => @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) },
|
|
379
|
+
"created_at" => @created_at,
|
|
380
|
+
"updated_at" => @updated_at,
|
|
381
|
+
"status" => @status,
|
|
382
|
+
"last_prompt" => @last_prompt,
|
|
383
|
+
"first_preview" => @first_preview,
|
|
384
|
+
"test_run" => !!@test_run,
|
|
385
|
+
"pending_question" => @pending_question ? scrub_utf8(stringify_message_keys(@pending_question)) : nil,
|
|
386
|
+
"used_memory_names" => Array(@used_memory_names),
|
|
387
|
+
"project_root" => @project_root,
|
|
388
|
+
"preloaded_memory_names" => Array(@preloaded_memory_names),
|
|
389
|
+
"muted_memory_names" => Array(@muted_memory_names),
|
|
390
|
+
"parent_id" => @parent_id
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
File.write(temp_path, JSON.pretty_generate(record) + "\n")
|
|
394
|
+
File.rename(temp_path, path)
|
|
395
|
+
self
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Mark a session as running.
|
|
399
|
+
def self.mark_running(session_id, state_dir: default_state_dir)
|
|
400
|
+
session = load(session_id, state_dir: state_dir)
|
|
401
|
+
session.status = STATUS_RUNNING
|
|
402
|
+
session.save(state_dir: state_dir)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Mark a session as completed.
|
|
406
|
+
def self.mark_completed(session_id, state_dir: default_state_dir)
|
|
407
|
+
session = load(session_id, state_dir: state_dir)
|
|
408
|
+
session.status = STATUS_COMPLETED
|
|
409
|
+
session.save(state_dir: state_dir)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Mark a session as errored.
|
|
413
|
+
def self.mark_error(session_id, reason:, state_dir: default_state_dir)
|
|
414
|
+
session = load(session_id, state_dir: state_dir)
|
|
415
|
+
session.status = STATUS_ERROR
|
|
416
|
+
session.last_prompt = reason.to_s
|
|
417
|
+
session.save(state_dir: state_dir)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Mark a session as stopped.
|
|
421
|
+
def self.mark_stopped(session_id, state_dir: default_state_dir)
|
|
422
|
+
session = load(session_id, state_dir: state_dir)
|
|
423
|
+
session.status = STATUS_STOPPED
|
|
424
|
+
session.save(state_dir: state_dir)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Directory for a specific session (holds IPC files alongside session.json).
|
|
428
|
+
def self.session_dir(session_id, state_dir: default_state_dir)
|
|
429
|
+
File.join(state_dir, session_id)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Default sessions directory path.
|
|
433
|
+
def self.default_sessions_dir
|
|
434
|
+
default_state_dir
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# XDG-aware sessions directory.
|
|
438
|
+
def self.default_state_dir(env: ENV)
|
|
439
|
+
xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
|
|
440
|
+
base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
441
|
+
File.join(base, STATE_SUBDIR)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Derive and cache the first user-message preview in the session record.
|
|
445
|
+
# Returns true if the cached value was set or updated.
|
|
446
|
+
def compute_first_preview!
|
|
447
|
+
return false if @first_preview && !@first_preview.empty?
|
|
448
|
+
|
|
449
|
+
first_user = @messages.find { |m| m[:role].to_s == "user" || m["role"].to_s == "user" }
|
|
450
|
+
return false unless first_user
|
|
451
|
+
|
|
452
|
+
preview = self.class.preview_of(first_user[:content] || first_user["content"])
|
|
453
|
+
return false if preview.empty?
|
|
454
|
+
|
|
455
|
+
@first_preview = preview
|
|
456
|
+
true
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
|
|
460
|
+
def self.preview_of(text)
|
|
461
|
+
norm = text.to_s.gsub(/\s+/, " ").strip
|
|
462
|
+
norm.length > 80 ? "#{norm[0, 80]}…" : norm
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
private
|
|
466
|
+
|
|
467
|
+
def stringify_message_keys(hash)
|
|
468
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# The last guard before JSON.generate, which raises on bytes that
|
|
472
|
+
# aren't UTF-8: a save that raised would lose the whole conversation.
|
|
473
|
+
# ToolRunner already scrubs tool output; this covers any other source.
|
|
474
|
+
def scrub_utf8(obj)
|
|
475
|
+
case obj
|
|
476
|
+
when String
|
|
477
|
+
str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
|
|
478
|
+
str.valid_encoding? ? str : str.scrub("?")
|
|
479
|
+
when Array then obj.map { |element| scrub_utf8(element) }
|
|
480
|
+
when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
|
|
481
|
+
else obj
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
class << self
|
|
486
|
+
private
|
|
487
|
+
|
|
488
|
+
# A chat turn's tool_calls get symbol keys too ({id:, name:,
|
|
489
|
+
# arguments:}); the arguments keep the model's string keys. So do
|
|
490
|
+
# image refs ({file:, mime:, width:, …}).
|
|
491
|
+
def symbolize_message_keys(hash)
|
|
492
|
+
message = hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
493
|
+
%i[tool_calls images].each do |key|
|
|
494
|
+
next unless message[key].is_a?(Array)
|
|
495
|
+
|
|
496
|
+
message[key] = message[key].map do |entry|
|
|
497
|
+
entry.is_a?(Hash) ? entry.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } : entry
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
message
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def session_path(session_id, state_dir:)
|
|
504
|
+
File.join(state_dir, "#{session_id}#{FILE_EXT}")
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|