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,549 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "monitor"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "idle_client"
|
|
9
|
+
require_relative "output_formatter"
|
|
10
|
+
require_relative "recap_store"
|
|
11
|
+
require_relative "log"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# Idle job for the session-recap feature — polled by the shared
|
|
15
|
+
# IdleScheduler (one background thread for the whole idle layer).
|
|
16
|
+
#
|
|
17
|
+
# Owns the inactivity clock bookkeeping shared across UIs: it reads the
|
|
18
|
+
# Engine's `last_activity_at` / `activity_seq` / `turn_running?` seam (the
|
|
19
|
+
# single source of truth) rather than tracking its own timeline, so the
|
|
20
|
+
# clock behaves identically for the interactive REPL and any Engine-backed
|
|
21
|
+
# worker.
|
|
22
|
+
#
|
|
23
|
+
# When the session has been idle for `@inactivity` seconds, no turn is
|
|
24
|
+
# running, and there are >= `@min_user_turns` user turns, the job
|
|
25
|
+
# snapshots the conversation (as a JSON string, never mutating it), summarizes
|
|
26
|
+
# it with a decoupled #IdleClient on a background thread, and emits a
|
|
27
|
+
# `:recap_ready` event (with a generation id so an invalidated recap can't
|
|
28
|
+
# render). Failures (server down, timeout, short history) are isolated and
|
|
29
|
+
# never break the active session.
|
|
30
|
+
class IdleRecap
|
|
31
|
+
DEFAULT_INACTIVITY_SECONDS = 180.0
|
|
32
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
33
|
+
DEFAULT_MIN_USER_TURNS = 2
|
|
34
|
+
# Above this many tool calls we ask only for a goal/achievement summary
|
|
35
|
+
# (never enumerate calls).
|
|
36
|
+
LARGE_TOOL_THRESHOLD = 10
|
|
37
|
+
# The most new transcript one attempt sends (the tail is kept): a first
|
|
38
|
+
# recap of a long session, or a long stretch since the last one.
|
|
39
|
+
MAX_NEW_CHARS = 16_000
|
|
40
|
+
|
|
41
|
+
# Build a cleaned recap transcript + the prompt text from a JSON snapshot.
|
|
42
|
+
# Drops the system prompt and tool internals (call markup with its
|
|
43
|
+
# arguments, tool outputs), keeps user turns and model prose (thoughts
|
|
44
|
+
# stripped), and collects the tool names for the prompt.
|
|
45
|
+
module TranscriptFilter
|
|
46
|
+
# A tool call as the model wrote it inline: gemma, qwen, or the qwen
|
|
47
|
+
# prompt-literal form. An unterminated gemma call runs to the end.
|
|
48
|
+
TOOL_CALL_RE = /<\|tool_call>(?:.*?<tool_call\|>|.*\z)|<tool_call>.*?<\/tool_call>|\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN\]\].*?\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE\]\]/m
|
|
49
|
+
# Each dispatched call's output starts with "[name]"; the kernel loop
|
|
50
|
+
# joins one step's outputs into a single tool_response with this
|
|
51
|
+
# separator, while the chat loop writes one message per call.
|
|
52
|
+
TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
|
|
53
|
+
TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
def build(messages)
|
|
58
|
+
Array(messages).filter_map do |message|
|
|
59
|
+
next unless message.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
case message["role"]
|
|
62
|
+
when "user"
|
|
63
|
+
# An image is a line naming it (refs only, never its bytes).
|
|
64
|
+
[message["content"].to_s, *image_lines(message["images"])].reject(&:empty?).join("\n")
|
|
65
|
+
when "model", "assistant"
|
|
66
|
+
strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
|
|
67
|
+
else
|
|
68
|
+
# Drop system prompt + tool_response contents + anything else.
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end.reject { |line| line.to_s.strip.empty? }.join("\n\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<String>] one tool name per dispatched call, in order
|
|
75
|
+
def tool_names(messages)
|
|
76
|
+
Array(messages).flat_map do |message|
|
|
77
|
+
next [] unless message.is_a?(Hash) && message["role"] == "tool_response"
|
|
78
|
+
|
|
79
|
+
message["content"].to_s.split(TOOL_OUTPUT_SEPARATOR).filter_map do |chunk|
|
|
80
|
+
chunk[TOOL_OUTPUT_HEADER_RE, 1]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def image_lines(images)
|
|
86
|
+
Array(images).filter_map { |ref| "[image #{ref["name"] || File.basename(ref["file"].to_s)}]" if ref.is_a?(Hash) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def strip_thought(text)
|
|
90
|
+
OutputFormatter.strip(IdleClient.strip_thinking(text))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Builds the recap prompt: the instructions as a system message and the
|
|
95
|
+
# transcript as the user message (R0 spike: with one user message Ornith
|
|
96
|
+
# opened 3/12 recaps with notes about the task; 0/12 this way). Short (2-4
|
|
97
|
+
# sentences) and centred on outcomes: the task, what came of it, what is
|
|
98
|
+
# open. Passive voice — no actors at all (voice spike 2026-09-25: the
|
|
99
|
+
# 3rd-person "the user was…/the assistant…" projection read as a third
|
|
100
|
+
# party watching; "we" was consistent but the user preferred outcomes
|
|
101
|
+
# without persons; the small model occasionally drifts back to naming
|
|
102
|
+
# actors, which is tolerated). Names the handful of tool calls only when
|
|
103
|
+
# they matter to the result; states only the count when it is large (never
|
|
104
|
+
# enumerates). With a previous recap it asks for an updated recap of the
|
|
105
|
+
# whole session from that recap plus the transcript since.
|
|
106
|
+
module RecapPrompt
|
|
107
|
+
# Recap-length S0 spike (Ornith 35B-A3B, 4 sessions): without the
|
|
108
|
+
# example opening, first recaps still began "The user asked the
|
|
109
|
+
# assistant to…" (goal first 25% → 100% with it); without "never by
|
|
110
|
+
# name" it named the user and Chi; without the side-details list it
|
|
111
|
+
# kept personal details, repo counts, paths and versions.
|
|
112
|
+
# Recap-polish spike (same 4 sessions): without the "lacks" line 4/20
|
|
113
|
+
# first recaps at 2-4 said "…weren't captured in the transcript"; 0/80
|
|
114
|
+
# with it. (A "short sentences, no dashes" line was dropped: with this
|
|
115
|
+
# one it brought back "Chi" as an actor in 15-18/80.)
|
|
116
|
+
# Voice spike (same 4 sessions, 2026-09-25): the passive "no actors"
|
|
117
|
+
# rule reads like a personal changelog ("Explored… then implemented…");
|
|
118
|
+
# it drifted to "the user and assistant" on 1/4 (a session with clear
|
|
119
|
+
# user decisions) — tolerated. The "we" variant was 4/4 consistent.
|
|
120
|
+
# %<plain>s is the range, e.g. "2-4 plain sentences"; %<shape>s is SHAPE
|
|
121
|
+
# or ONE_SHAPE.
|
|
122
|
+
SYSTEM = "You write short recaps of a chat between a user and an assistant, for the user " \
|
|
123
|
+
"coming back to it later. Reply with the recap only: %<plain>s, no heading, " \
|
|
124
|
+
"no preamble, no notes about the task or the transcript. Do not mention what the " \
|
|
125
|
+
"transcript lacks or does not say; leave it out. Centre it on outcomes, not on " \
|
|
126
|
+
"the order of events. %<shape>s Keep project names " \
|
|
127
|
+
"and facts that matter for continuing. Do not retell the chat turn by turn (\"the user " \
|
|
128
|
+
"asked..., then the assistant...\"). Never name who did something: no \"the user\", no " \
|
|
129
|
+
"\"the assistant\", no \"I\", no \"we\", no personal names — state outcomes and decisions " \
|
|
130
|
+
"without actors (\"a 28-day threshold was chosen\", \"the plan was saved\"). Leave out " \
|
|
131
|
+
"side details: personal " \
|
|
132
|
+
"details about the user (where they live, their accounts, how many repos they have), " \
|
|
133
|
+
"file paths and version numbers, unless they are the point. If there was no clear task, " \
|
|
134
|
+
"just say what was talked about."
|
|
135
|
+
SHAPE = "The first sentence names the task itself (\"Checking how the parser handles tabs\"), " \
|
|
136
|
+
"not who asked for it. Then say what came of it: results, decisions, findings. End with " \
|
|
137
|
+
"what is still open or the next step, if anything."
|
|
138
|
+
# recap.sentences: 1. Asking for "1 plain sentence" next to SHAPE's
|
|
139
|
+
# first sentence, then results, then what is open gave 3.4 sentences
|
|
140
|
+
# (1 exactly in 0/20, spike); with this shape 1 in 40/40, goal first.
|
|
141
|
+
ONE_SHAPE = "The sentence names the task itself (\"Checking how the parser handles tabs\"), not " \
|
|
142
|
+
"who asked for it, and where it stands: the result, or what is still open."
|
|
143
|
+
# "do not just repeat": otherwise Ornith returned the earlier recap
|
|
144
|
+
# word for word after a short turn.
|
|
145
|
+
UPDATE = " You are given the earlier recap and the conversation since the earlier recap: " \
|
|
146
|
+
"write an updated recap of the whole session that also covers what happened since " \
|
|
147
|
+
"(do not just repeat the earlier recap)."
|
|
148
|
+
# Holds the length once the recap covers more (S0: an updated recap ran
|
|
149
|
+
# to 6 sentences for 2-4 without it, within range+1 in 95%+ with it).
|
|
150
|
+
# The first-sentence line keeps the list preview on the task.
|
|
151
|
+
UPDATE_LENGTH = " Keep it to %<sentences>s even though it now covers more: merge or drop older " \
|
|
152
|
+
"details rather than adding sentences. Keep %<focus>s on the task (change it " \
|
|
153
|
+
"if the focus moved)."
|
|
154
|
+
# At an open continue offer (the last turn ran out of steps with a
|
|
155
|
+
# tool call pending). Recap-next F1 spike: without it the recap stated
|
|
156
|
+
# the stop in 5-10% and said "no open items" 7/40; with this line
|
|
157
|
+
# after the transcript 95-100% and 0. A system sentence as well made
|
|
158
|
+
# it worse (a chit-chat session judged there was no task). Worded to
|
|
159
|
+
# stay true after a worker restart, when the offer itself is gone.
|
|
160
|
+
OFFER_LINE = "Where it stands now: the last turn stopped at its step limit before the task was finished."
|
|
161
|
+
OMITTED = "(earlier part omitted)"
|
|
162
|
+
DEFAULT_SENTENCES = [2, 4].freeze
|
|
163
|
+
MAX_SENTENCES = 10
|
|
164
|
+
SENTENCES_RE = /\A(\d+)(?:\s*[-–]\s*(\d+))?\z/
|
|
165
|
+
|
|
166
|
+
module_function
|
|
167
|
+
|
|
168
|
+
# The recap.sentences setting as [min, max]: "2-3", "3" or 3 (an en
|
|
169
|
+
# dash and spaces are fine). DEFAULT_SENTENCES when unset; nil when
|
|
170
|
+
# invalid (outside 1..MAX_SENTENCES, min above max, not a number).
|
|
171
|
+
def sentences_range(value)
|
|
172
|
+
text = value.to_s.strip
|
|
173
|
+
return DEFAULT_SENTENCES if text.empty?
|
|
174
|
+
|
|
175
|
+
match = SENTENCES_RE.match(text)
|
|
176
|
+
return nil unless match
|
|
177
|
+
|
|
178
|
+
min = match[1].to_i
|
|
179
|
+
max = (match[2] || match[1]).to_i
|
|
180
|
+
return nil unless min.between?(1, MAX_SENTENCES) && max.between?(min, MAX_SENTENCES)
|
|
181
|
+
|
|
182
|
+
[min, max]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [Array<Hash>, nil] chat messages; nil when there is no
|
|
186
|
+
# transcript to summarize
|
|
187
|
+
# @param sentences [Array(Integer, Integer)] the range, from #sentences_range
|
|
188
|
+
# @param offer [Boolean] a continue offer is open: add OFFER_LINE
|
|
189
|
+
def build(transcript, tool_count: nil, tool_names: [], previous: nil, sentences: DEFAULT_SENTENCES, offer: false)
|
|
190
|
+
body = cap(transcript.to_s.strip)
|
|
191
|
+
return nil if body.empty?
|
|
192
|
+
|
|
193
|
+
tool_count ||= tool_names.size
|
|
194
|
+
count_word = tool_count == 1 ? "1 tool call" : "#{tool_count} tool calls"
|
|
195
|
+
tools = if tool_count > LARGE_TOOL_THRESHOLD
|
|
196
|
+
" #{count_word}#{tools_used(tool_names)} were made. Do not enumerate the tool calls."
|
|
197
|
+
elsif tool_count.positive?
|
|
198
|
+
" About #{count_word}#{tools_used(tool_names)} were made; name them only if they " \
|
|
199
|
+
"matter to the result."
|
|
200
|
+
else
|
|
201
|
+
""
|
|
202
|
+
end
|
|
203
|
+
one = sentences.last == 1
|
|
204
|
+
range = { plain: sentences_text(sentences, "plain "), sentences: sentences_text(sentences),
|
|
205
|
+
shape: one ? ONE_SHAPE : SHAPE, focus: one ? "it" : "the first sentence" }
|
|
206
|
+
system = format(SYSTEM, range) + (previous ? UPDATE + format(UPDATE_LENGTH, range) : "") + tools
|
|
207
|
+
user = +""
|
|
208
|
+
user << "Earlier recap:\n#{previous}\n\n" if previous
|
|
209
|
+
user << "Transcript#{previous ? ' since the earlier recap' : ''} (the system prompt and tool " \
|
|
210
|
+
"internals were removed; only user turns and assistant prose remain):\n---\n#{body}\n---\n"
|
|
211
|
+
user << "#{OFFER_LINE}\n" if offer
|
|
212
|
+
user << "Write the recap now."
|
|
213
|
+
[{ role: "system", content: system }, { role: "user", content: user }]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# "2-4 sentences", or "3 sentences" for [3, 3] ("1 sentence" for [1, 1])
|
|
217
|
+
def sentences_text((min, max), adjective = "")
|
|
218
|
+
count = min == max ? min.to_s : "#{min}-#{max}"
|
|
219
|
+
"#{count} #{adjective}#{max == 1 ? 'sentence' : 'sentences'}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The tail of +body+ when it is over MAX_NEW_CHARS, from a paragraph
|
|
223
|
+
# start when one is near, after an "(earlier part omitted)" line.
|
|
224
|
+
def cap(body)
|
|
225
|
+
return body if body.size <= MAX_NEW_CHARS
|
|
226
|
+
|
|
227
|
+
tail = body[-MAX_NEW_CHARS..]
|
|
228
|
+
cut = tail.index("\n\n")
|
|
229
|
+
tail = tail[(cut + 2)..] if cut && cut < 2_000
|
|
230
|
+
"#{OMITTED}\n\n#{tail}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# " (execute x3, read_file)", or "" when no names are known
|
|
234
|
+
def tools_used(names)
|
|
235
|
+
return "" if names.empty?
|
|
236
|
+
|
|
237
|
+
" (#{names.tally.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(', ')})"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @return [Array(Integer, Integer)] the recap length range, e.g. [2, 4]
|
|
242
|
+
attr_reader :generation, :inactivity, :min_user_turns, :sentences
|
|
243
|
+
|
|
244
|
+
# @param callable [#call] true while a continue offer waits for an
|
|
245
|
+
# answer (the Worker's or the REPL's TurnFlow); read at each attempt
|
|
246
|
+
attr_writer :awaiting_continue
|
|
247
|
+
|
|
248
|
+
# @return [Hash, nil] the last recap written: {text:, covered:,
|
|
249
|
+
# covered_digest:, model:, created_at:}, where covered counts the
|
|
250
|
+
# session messages it summarizes and covered_digest fingerprints the
|
|
251
|
+
# last of them. With a store, loaded from it for each new session.
|
|
252
|
+
def state
|
|
253
|
+
@mutex.synchronize do
|
|
254
|
+
if @store && @store_key != (key = @store.key)
|
|
255
|
+
@store_key = key
|
|
256
|
+
@state = @store.load
|
|
257
|
+
end
|
|
258
|
+
@state&.dup
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The recap asks either a fixed model (+model+, +base_url+,
|
|
263
|
+
# +api_key_env+: an explicit recap: config) or +target+, called at each
|
|
264
|
+
# attempt (the session's current model, so a /model switch counts).
|
|
265
|
+
# @param base_url [String] the OpenAI API base the recap asks
|
|
266
|
+
# @param api_key_env [String, nil] the variable holding its key
|
|
267
|
+
# @param target [#call, nil] -> {base_url:, api_key_env:, model:, label:}
|
|
268
|
+
def initialize(engine:, model: nil, base_url: nil, api_key_env: nil, target: nil,
|
|
269
|
+
inactivity: DEFAULT_INACTIVITY_SECONDS,
|
|
270
|
+
min_user_turns: DEFAULT_MIN_USER_TURNS,
|
|
271
|
+
timeout: DEFAULT_TIMEOUT_SECONDS,
|
|
272
|
+
sentences: RecapPrompt::DEFAULT_SENTENCES,
|
|
273
|
+
client: nil,
|
|
274
|
+
store: nil,
|
|
275
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
276
|
+
raise ArgumentError, "IdleRecap requires an engine" unless engine
|
|
277
|
+
|
|
278
|
+
@engine = engine
|
|
279
|
+
@target = target || lambda {
|
|
280
|
+
{ base_url: base_url, api_key_env: api_key_env, model: model, label: model }
|
|
281
|
+
}
|
|
282
|
+
@inactivity = inactivity
|
|
283
|
+
@min_user_turns = min_user_turns
|
|
284
|
+
@timeout = timeout
|
|
285
|
+
@sentences = sentences
|
|
286
|
+
# Specs inject a client; otherwise one IdleClient per target, rebuilt
|
|
287
|
+
# when the target changes.
|
|
288
|
+
@client_override = client
|
|
289
|
+
@client = nil
|
|
290
|
+
@client_key = nil
|
|
291
|
+
@clock = clock
|
|
292
|
+
@store = store
|
|
293
|
+
|
|
294
|
+
@mutex = Monitor.new
|
|
295
|
+
# Serializes the attempt state machine (#tick on the scheduler thread,
|
|
296
|
+
# #request_now on a Bridge or REPL thread, #write_now). Never held by
|
|
297
|
+
# #state, so a snapshot reading it doesn't wait on an emit.
|
|
298
|
+
@run_mutex = Monitor.new
|
|
299
|
+
@generation = 0
|
|
300
|
+
@last_fire_activity_seq = nil
|
|
301
|
+
@awaiting_continue = -> { false }
|
|
302
|
+
@in_flight = nil
|
|
303
|
+
@state = nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Mark any in-flight recap stale (called when a new turn starts). The
|
|
307
|
+
# generation id bumps so the summarizing worker sees the mismatch and drops
|
|
308
|
+
# its result instead of rendering a recap for a turn that is now running.
|
|
309
|
+
def invalidate!
|
|
310
|
+
@mutex.synchronize { @generation += 1 }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# One detector step, called by the shared IdleScheduler. Public so specs
|
|
314
|
+
# can drive it deterministically. Never waits on the summarizer: it
|
|
315
|
+
# collects a finished (or overdue) request, else starts one when
|
|
316
|
+
# eligible, so the other idle jobs keep ticking meanwhile.
|
|
317
|
+
def tick
|
|
318
|
+
@run_mutex.synchronize do
|
|
319
|
+
next collect if in_flight?
|
|
320
|
+
next unless should_fire?
|
|
321
|
+
|
|
322
|
+
start
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Ask for a recap now (/recap): start an attempt at once, without the
|
|
327
|
+
# inactivity window; the scheduler collects it as usual.
|
|
328
|
+
# @return [Symbol] :started, :in_flight, :busy (a turn runs),
|
|
329
|
+
# :too_short, :nothing_new or :failed
|
|
330
|
+
def request_now
|
|
331
|
+
@run_mutex.synchronize do
|
|
332
|
+
next :in_flight if in_flight?
|
|
333
|
+
next :busy if @engine.turn_running?
|
|
334
|
+
|
|
335
|
+
start
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Write a recap now and wait for it, bounded by the timeout: the worker
|
|
340
|
+
# (or the REPL) leaving. Skips the inactivity window and the once-per-
|
|
341
|
+
# window latch, not the rules on what to recap (minimum user turns,
|
|
342
|
+
# something new). Takes over an attempt already in flight. Call it with
|
|
343
|
+
# the idle scheduler stopped.
|
|
344
|
+
# @param on_start [#call, nil] called when a request goes out
|
|
345
|
+
# @return [String, nil] the recap written, nil when none was
|
|
346
|
+
def write_now(on_start: nil)
|
|
347
|
+
@run_mutex.synchronize do
|
|
348
|
+
unless in_flight?
|
|
349
|
+
start
|
|
350
|
+
on_start&.call if in_flight?
|
|
351
|
+
end
|
|
352
|
+
job = @in_flight
|
|
353
|
+
next nil unless job
|
|
354
|
+
|
|
355
|
+
job[:thread].join([job[:deadline] - @clock.call, 0].max)
|
|
356
|
+
if job[:thread].alive?
|
|
357
|
+
@in_flight = nil # overdue: left to its IdleClient timeout
|
|
358
|
+
next nil
|
|
359
|
+
end
|
|
360
|
+
before = @state
|
|
361
|
+
collect
|
|
362
|
+
@state.equal?(before) ? nil : @state[:text]
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# @return [Hash] the model the next attempt asks: {base_url:,
|
|
367
|
+
# api_key_env:, model:, label:}
|
|
368
|
+
def target
|
|
369
|
+
@target.call
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# @return [Boolean] true while a summarize request is running
|
|
373
|
+
def in_flight?
|
|
374
|
+
!@in_flight.nil?
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# @return [Boolean] true when a recap is eligible to fire right now.
|
|
378
|
+
def should_fire?
|
|
379
|
+
return false if @engine.turn_running?
|
|
380
|
+
return false unless last_idle_seconds >= @inactivity
|
|
381
|
+
# Fire at most once per idle window: require that activity advanced the
|
|
382
|
+
# shared seq since the last fire. Pure monotonic time passing does NOT
|
|
383
|
+
# bump the seq, so a long idle window only summarizes once.
|
|
384
|
+
return true if @last_fire_activity_seq.nil?
|
|
385
|
+
|
|
386
|
+
@engine.activity_seq > @last_fire_activity_seq
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
private
|
|
390
|
+
|
|
391
|
+
# A failing check writes the recap without the offer line.
|
|
392
|
+
def offer_open?
|
|
393
|
+
@awaiting_continue.call ? true : false
|
|
394
|
+
rescue StandardError
|
|
395
|
+
false
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def last_idle_seconds
|
|
399
|
+
@clock.call - @engine.last_activity_at
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Start an attempt: snapshot, build the prompt, and spawn the summarize
|
|
403
|
+
# thread. The result is picked up by #collect on a later tick.
|
|
404
|
+
# @return [Symbol] :started, :too_short, :nothing_new or :failed
|
|
405
|
+
def start
|
|
406
|
+
gen = bump_generation
|
|
407
|
+
# Latch the attempt, not the success: a short history, a failed or
|
|
408
|
+
# empty summary, or an invalidated run must not re-fire on every
|
|
409
|
+
# scheduler tick. The next recorded activity re-arms the window.
|
|
410
|
+
@last_fire_activity_seq = @engine.activity_seq
|
|
411
|
+
parsed = safe_parse(@engine.messages_json_for_recap)
|
|
412
|
+
return :too_short if parsed.nil?
|
|
413
|
+
|
|
414
|
+
user_turns = parsed.count { |message| message.is_a?(Hash) && message["role"] == "user" }
|
|
415
|
+
if parsed.empty? || user_turns < @min_user_turns
|
|
416
|
+
drop_stale(parsed)
|
|
417
|
+
return :too_short
|
|
418
|
+
end
|
|
419
|
+
previous = continuable_state(parsed)
|
|
420
|
+
fresh = parsed.drop(previous ? previous[:covered] : 0)
|
|
421
|
+
transcript = TranscriptFilter.build(fresh)
|
|
422
|
+
# Nothing new said (only notes, tool traffic, or no messages at all):
|
|
423
|
+
# the recap still stands, so no request.
|
|
424
|
+
return :nothing_new if transcript.strip.empty?
|
|
425
|
+
prompt = RecapPrompt.build(transcript, tool_names: TranscriptFilter.tool_names(fresh), previous: previous&.dig(:text),
|
|
426
|
+
sentences: @sentences, offer: offer_open?)
|
|
427
|
+
return :nothing_new if prompt.nil?
|
|
428
|
+
asked = target
|
|
429
|
+
@in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
|
|
430
|
+
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked[:label] }
|
|
431
|
+
:started
|
|
432
|
+
rescue StandardError
|
|
433
|
+
:failed
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Pick up the in-flight attempt. Save/emit happens only here, on the
|
|
437
|
+
# scheduler thread, never from the summarize thread: a stale (turn
|
|
438
|
+
# started) or overdue attempt is dropped. An overdue thread is left to
|
|
439
|
+
# its IdleClient timeout (the same budget), not killed mid-request.
|
|
440
|
+
def collect
|
|
441
|
+
job = @in_flight
|
|
442
|
+
unless valid_generation?(job[:generation])
|
|
443
|
+
@in_flight = nil
|
|
444
|
+
return
|
|
445
|
+
end
|
|
446
|
+
if job[:thread].alive?
|
|
447
|
+
@in_flight = nil if @clock.call >= job[:deadline]
|
|
448
|
+
return
|
|
449
|
+
end
|
|
450
|
+
@in_flight = nil
|
|
451
|
+
recap = safe_value(job[:thread])
|
|
452
|
+
return if recap.nil? || recap.to_s.strip.empty?
|
|
453
|
+
# An IdleClient::Summary names the model that answered; a plain String
|
|
454
|
+
# (a stubbed client) doesn't, and the asked label stands in.
|
|
455
|
+
served = recap.model if recap.respond_to?(:model)
|
|
456
|
+
text = recap.to_s
|
|
457
|
+
saved = { text: text, covered: job[:covered], covered_digest: job[:covered_digest],
|
|
458
|
+
model: served || job[:model], created_at: Time.now.utc.iso8601 }
|
|
459
|
+
@mutex.synchronize { @state = saved }
|
|
460
|
+
save(saved)
|
|
461
|
+
@engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered])
|
|
462
|
+
rescue StandardError
|
|
463
|
+
@in_flight = nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# A history too short for a recap that no longer holds what the saved one
|
|
467
|
+
# covers (a "no" at a continue offer took the offered turn back): the
|
|
468
|
+
# saved recap would keep describing that turn, so it goes.
|
|
469
|
+
def drop_stale(messages)
|
|
470
|
+
return unless state
|
|
471
|
+
return if continuable_state(messages)
|
|
472
|
+
|
|
473
|
+
@mutex.synchronize { @state = nil }
|
|
474
|
+
@store&.delete
|
|
475
|
+
rescue StandardError => e
|
|
476
|
+
Log.warn(:recap, "delete_failed", echo: "[IdleRecap] dropping a stale recap failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def save(state)
|
|
480
|
+
@store&.save(state)
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
Log.warn(:recap, "save_failed", echo: "[IdleRecap] saving the recap failed: #{e.class}: #{e.message}", error: e.class.name)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# The saved state when it still describes a prefix of +messages+; nil
|
|
486
|
+
# (start over) when the history got shorter or was rewritten (a
|
|
487
|
+
# rollback, a cancelled or failed turn replaced).
|
|
488
|
+
def continuable_state(messages)
|
|
489
|
+
saved = state
|
|
490
|
+
return nil unless saved && saved[:covered].to_i.positive?
|
|
491
|
+
return nil if saved[:covered] > messages.size
|
|
492
|
+
return nil unless self.class.digest(messages[saved[:covered] - 1]) == saved[:covered_digest]
|
|
493
|
+
|
|
494
|
+
saved
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# SHA1 of one message's role and text: detects a rewrite a count alone
|
|
498
|
+
# misses. Model text is taken without its thinking, which is dropped from
|
|
499
|
+
# older model messages when the next turn starts.
|
|
500
|
+
def self.digest(message)
|
|
501
|
+
return nil unless message.is_a?(Hash)
|
|
502
|
+
|
|
503
|
+
text = message["content"].to_s
|
|
504
|
+
text = TranscriptFilter.strip_thought(text) if %w[model assistant].include?(message["role"])
|
|
505
|
+
Digest::SHA1.hexdigest("#{message['role']}\0#{text}")
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def bump_generation
|
|
509
|
+
@mutex.synchronize { @generation += 1 }
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def valid_generation?(gen)
|
|
513
|
+
@mutex.synchronize { @generation == gen }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def client_for(asked)
|
|
517
|
+
return @client_override if @client_override
|
|
518
|
+
|
|
519
|
+
key = asked.values_at(:base_url, :api_key_env, :model)
|
|
520
|
+
unless @client && @client_key == key
|
|
521
|
+
@client = IdleClient.new(model: asked[:model], base_url: asked[:base_url], api_key_env: asked[:api_key_env], timeout: @timeout)
|
|
522
|
+
@client_key = key
|
|
523
|
+
end
|
|
524
|
+
@client
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def spawn_summarize(client, prompt)
|
|
528
|
+
Thread.new do
|
|
529
|
+
client.summarize(prompt)
|
|
530
|
+
rescue StandardError => e
|
|
531
|
+
# No recap this time (the next idle window tries again); say why.
|
|
532
|
+
Log.exception(:recap, "summarize_failed", e)
|
|
533
|
+
nil
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def safe_value(worker)
|
|
538
|
+
worker.value
|
|
539
|
+
rescue StandardError
|
|
540
|
+
nil
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def safe_parse(json)
|
|
544
|
+
JSON.parse(json)
|
|
545
|
+
rescue StandardError
|
|
546
|
+
[]
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
require_relative "reminder_store"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Idle job for periodic reminders — polled by the shared IdleScheduler
|
|
9
|
+
# (one background thread for the whole idle layer).
|
|
10
|
+
#
|
|
11
|
+
# Two delivery paths cooperate:
|
|
12
|
+
# 1. Pull-based: Engine#maybe_inject_reminders / collect_due_reminders
|
|
13
|
+
# reads ReminderStore#due_reminders at turn start → injects
|
|
14
|
+
# [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
|
|
15
|
+
# 2. Synthetic turns: when this job's tick finds due reminders while
|
|
16
|
+
# idle, it latches @due_reminder_name and fires @auto_turn_callback
|
|
17
|
+
# (the TUI/SessionManager queue a synthetic turn from the latch).
|
|
18
|
+
class IdleReminders
|
|
19
|
+
DEFAULT_MIN_INACTIVITY_SECONDS = 60.0 # Minimum idle time before checking for reminders
|
|
20
|
+
|
|
21
|
+
attr_reader :inactivity
|
|
22
|
+
|
|
23
|
+
def initialize(engine:, inactivity: DEFAULT_MIN_INACTIVITY_SECONDS,
|
|
24
|
+
reminder_store: nil,
|
|
25
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
26
|
+
callback: nil)
|
|
27
|
+
raise ArgumentError, "IdleReminders requires an engine" unless engine
|
|
28
|
+
|
|
29
|
+
@engine = engine
|
|
30
|
+
@inactivity = inactivity
|
|
31
|
+
@reminder_store = reminder_store || engine.instance_variable_get(:@reminder_store)
|
|
32
|
+
@clock = clock
|
|
33
|
+
@auto_turn_callback = callback
|
|
34
|
+
|
|
35
|
+
@mutex = Monitor.new
|
|
36
|
+
@due_reminder_name = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get the currently pending due reminder name (for Engine to read).
|
|
40
|
+
# Kept for backward compatibility.
|
|
41
|
+
# Called by Engine#collect_due_reminders. Thread-safe.
|
|
42
|
+
def due_reminder_name
|
|
43
|
+
@mutex.synchronize { @due_reminder_name }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get all due reminders (from ReminderStore). Called by Engine#maybe_inject_reminders.
|
|
47
|
+
# Thread-safe. Returns all due reminders, not just one.
|
|
48
|
+
# @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
|
|
49
|
+
def due_reminders
|
|
50
|
+
@reminder_store&.due_reminders || []
|
|
51
|
+
end
|
|
52
|
+
# Get the names of all due reminders. Called by the background thread to
|
|
53
|
+
# determine which reminders need a synthetic turn.
|
|
54
|
+
# @return [Array<String>] reminder names that are due
|
|
55
|
+
def due_reminder_names
|
|
56
|
+
due_reminders.map { |r| r[:name] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Clear the pending due reminder (after it has been delivered).
|
|
60
|
+
# Called by Engine after injecting the reminder into the system prompt.
|
|
61
|
+
def clear_due
|
|
62
|
+
@mutex.synchronize { @due_reminder_name = nil }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# One detector step, called by the shared IdleScheduler. Public so specs
|
|
66
|
+
# can drive it deterministically.
|
|
67
|
+
def tick
|
|
68
|
+
return if @due_reminder_name # already due, waiting for engine to deliver
|
|
69
|
+
return unless should_check?
|
|
70
|
+
|
|
71
|
+
check_due_reminders
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] true when it's time to check for reminders.
|
|
75
|
+
def should_check?
|
|
76
|
+
return false if @engine.turn_running?
|
|
77
|
+
return false unless last_idle_seconds >= @inactivity
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def last_idle_seconds
|
|
84
|
+
@clock.call - @engine.last_activity_at
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_due_reminders
|
|
88
|
+
return unless @reminder_store
|
|
89
|
+
due_names = @reminder_store.due_reminders.map { |r| r[:name] }
|
|
90
|
+
return if due_names.empty?
|
|
91
|
+
# Signal the engine to create a synthetic turn via callback.
|
|
92
|
+
# The callback is responsible for triggering a turn (e.g. SessionManager
|
|
93
|
+
# writes a file, TerminalUI queues input). The engine's run_turn or
|
|
94
|
+
# REPL injection point then picks up and delivers the reminders.
|
|
95
|
+
@mutex.synchronize { @due_reminder_name = due_names.first }
|
|
96
|
+
if @auto_turn_callback
|
|
97
|
+
@auto_turn_callback.call(due_names)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|