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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A context note: background text pushed into a session from outside (the
|
|
7
|
+
# user's `chi note`, or another session's send_note). It is kept in the
|
|
8
|
+
# conversation as a tail system message marked kind: "note", framed so the
|
|
9
|
+
# model reads it as information, not as a request.
|
|
10
|
+
module ContextNote
|
|
11
|
+
KIND = "note"
|
|
12
|
+
# The message keys a note carries besides role and content; everything
|
|
13
|
+
# that copies messages field by field must keep them.
|
|
14
|
+
KEYS = %i[kind note_id source from_session from_cwd].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Takes SessionManager.read_note's keys.
|
|
19
|
+
# @return [Hash] the conversation message
|
|
20
|
+
def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, **)
|
|
21
|
+
{ role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
|
|
22
|
+
from_cwd: from_cwd, content: frame(text, source: source, created_at: created_at,
|
|
23
|
+
from_session: from_session, from_cwd: from_cwd) }.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil)
|
|
27
|
+
time = clock(created_at)
|
|
28
|
+
from = label(source: source, from_session: from_session, from_cwd: from_cwd)
|
|
29
|
+
"[CONTEXT NOTE from #{from}#{", #{time}" if time}]\n#{text}\n[END NOTE]"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Who sent a note: its source, or the sending session and its folder.
|
|
33
|
+
def label(source:, from_session: nil, from_cwd: nil)
|
|
34
|
+
return source.to_s unless from_session
|
|
35
|
+
|
|
36
|
+
"session #{from_session.to_s[0, 6]}#{" (#{home_relative(from_cwd)})" if from_cwd}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def label_of(message)
|
|
40
|
+
label(source: fetch(message, :source), from_session: fetch(message, :from_session), from_cwd: fetch(message, :from_cwd))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The note's own text, without its frame.
|
|
44
|
+
def text_of(message)
|
|
45
|
+
fetch(message, :content).to_s.sub(/\A\[CONTEXT NOTE[^\n]*\n/, "").sub(/\n\[END NOTE\]\z/, "")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch(message, key)
|
|
49
|
+
message.key?(key) ? message[key] : message[key.to_s]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def note?(message)
|
|
53
|
+
fetch(message, :kind).to_s == KIND
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The conversation with +system_message+ at its head: an old system
|
|
57
|
+
# prompt there (a system message of no kind) is replaced, anything else
|
|
58
|
+
# (a note that arrived before the first turn, a turn note left by a
|
|
59
|
+
# failed first turn) stays and the prompt goes before it.
|
|
60
|
+
def with_system_head(messages, system_message)
|
|
61
|
+
first = messages.first
|
|
62
|
+
replace = first && fetch(first, :role).to_s == "system" && fetch(first, :kind).to_s.empty?
|
|
63
|
+
[system_message] + (replace ? messages.drop(1) : messages)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clock(created_at)
|
|
67
|
+
created_at && Time.iso8601(created_at.to_s).localtime.strftime("%H:%M")
|
|
68
|
+
rescue ArgumentError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def home_relative(path)
|
|
73
|
+
home = Dir.home
|
|
74
|
+
path.to_s.start_with?("#{home}/") || path.to_s == home ? path.to_s.sub(home, "~") : path.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Context shown to the model as a quote above the user's own words, the
|
|
5
|
+
# shape the web annotations use (annotations.js quoteBlock), minus their
|
|
6
|
+
# source label: `pbpaste | chi send -m "same bug?" ID`.
|
|
7
|
+
module ContextQuote
|
|
8
|
+
# @param text [String, nil]
|
|
9
|
+
# @return [String, nil] each line as "> line" ("> " dropped for an empty
|
|
10
|
+
# one), ending in a blank line so the message follows as is; nil when
|
|
11
|
+
# the text is blank
|
|
12
|
+
def self.block(text)
|
|
13
|
+
lines = text.to_s.gsub(/\r\n?/, "\n").split("\n", -1).map { |line| line.sub(/[[:space:]]+\z/, "") }
|
|
14
|
+
lines.shift while lines.first&.empty?
|
|
15
|
+
lines.pop while lines.last&.empty?
|
|
16
|
+
return nil if lines.empty?
|
|
17
|
+
|
|
18
|
+
"#{lines.map { |line| line.empty? ? ">" : "> #{line}" }.join("\n")}\n\n"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "token_usage"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Normalizes raw model-server stream payloads into a uniform context-usage
|
|
7
|
+
# snapshot. Shared by the kernel loop (to inform the model of real token
|
|
8
|
+
# usage) and the terminal UI (to render the status line) so both agree on
|
|
9
|
+
# token accounting instead of duplicating the parsing logic.
|
|
10
|
+
module ContextUsage
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Numbers read like TokenUsage's: integers, floats and numeric strings,
|
|
14
|
+
# decimal ("010" is 10).
|
|
15
|
+
def first_positive_integer(*values)
|
|
16
|
+
TokenUsage.first_positive(*values)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# `window_tokens` is the caller's resolved context window; a window the
|
|
20
|
+
# payload reports itself (n_ctx / context_window) wins over it. Without
|
|
21
|
+
# either, context_window_tokens and ctx_pct stay nil: the parser does not
|
|
22
|
+
# invent a default.
|
|
23
|
+
def normalize(payload, window_tokens: nil)
|
|
24
|
+
payload_hash = payload.is_a?(Hash) ? payload : {}
|
|
25
|
+
usage = payload_hash["usage"] || payload_hash[:usage]
|
|
26
|
+
usage = {} unless usage.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
# The token counts come from the same parser SessionMetrics uses.
|
|
29
|
+
counts = TokenUsage.from_payload(payload_hash) || {}
|
|
30
|
+
prompt_tokens = counts[:prompt_tokens]
|
|
31
|
+
completion_tokens = counts[:completion_tokens]
|
|
32
|
+
|
|
33
|
+
total_tokens = first_positive_integer(
|
|
34
|
+
usage["total_tokens"], usage[:total_tokens],
|
|
35
|
+
payload_hash["total_tokens"], payload_hash[:total_tokens],
|
|
36
|
+
payload_hash["n_past"], payload_hash[:n_past]
|
|
37
|
+
)
|
|
38
|
+
total_tokens ||= prompt_tokens.to_i + completion_tokens.to_i if prompt_tokens || completion_tokens
|
|
39
|
+
|
|
40
|
+
context_window_tokens = first_positive_integer(
|
|
41
|
+
payload_hash["n_ctx"], payload_hash[:n_ctx],
|
|
42
|
+
payload_hash["context_window"], payload_hash[:context_window],
|
|
43
|
+
window_tokens
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
context_used_tokens = first_positive_integer(
|
|
47
|
+
payload_hash["n_past"], payload_hash[:n_past],
|
|
48
|
+
total_tokens
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
ctx_pct = if context_window_tokens && context_used_tokens
|
|
52
|
+
(context_used_tokens.to_f / context_window_tokens) * 100.0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return nil unless prompt_tokens || completion_tokens || total_tokens || ctx_pct
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
prompt_tokens: prompt_tokens,
|
|
59
|
+
completion_tokens: completion_tokens,
|
|
60
|
+
total_tokens: total_tokens,
|
|
61
|
+
context_window_tokens: context_window_tokens,
|
|
62
|
+
ctx_pct: ctx_pct
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The model's context window in tokens, and where that number came from.
|
|
7
|
+
#
|
|
8
|
+
# Lookup order:
|
|
9
|
+
# 1. the running server (Client#context_window, e.g. llama.cpp's n_ctx)
|
|
10
|
+
# 2. the host's model list (a chat adapter's #context_window, e.g. a
|
|
11
|
+
# provider's context_length)
|
|
12
|
+
# 3. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
|
|
13
|
+
# config file); it only fills in when the server reports nothing
|
|
14
|
+
# 4. DEFAULT_TOKENS
|
|
15
|
+
#
|
|
16
|
+
# Sources: :server, :model_list, :config (CLI or file), :env, :default.
|
|
17
|
+
module ContextWindow
|
|
18
|
+
DEFAULT_TOKENS = 256_000
|
|
19
|
+
|
|
20
|
+
Resolved = Struct.new(:tokens, :source, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param adapter [#context_window, nil] the host's chat adapter
|
|
25
|
+
def resolve(client: nil, model: nil, adapter: nil)
|
|
26
|
+
server_tokens = client.context_window(model: model) if client.respond_to?(:context_window)
|
|
27
|
+
if positive_integer?(server_tokens)
|
|
28
|
+
@last_server = Resolved.new(tokens: server_tokens, source: :server)
|
|
29
|
+
return @last_server
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
listed = adapter.context_window(model: model) if adapter.respond_to?(:context_window)
|
|
33
|
+
if positive_integer?(listed)
|
|
34
|
+
@last_server = Resolved.new(tokens: listed, source: :model_list)
|
|
35
|
+
return @last_server
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
configured
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The window without asking a server: config, env, then the default.
|
|
42
|
+
# `env` resolves against that env's config file instead of the live
|
|
43
|
+
# process config (for `chi self`).
|
|
44
|
+
def configured(env: nil)
|
|
45
|
+
value, origin = begin
|
|
46
|
+
if env
|
|
47
|
+
file_data = ConfigFile.read_yaml(path: ConfigFile.global_path(env: env))
|
|
48
|
+
Config.resolve_with_origin("context.window_tokens", file_data: file_data, env: env)
|
|
49
|
+
else
|
|
50
|
+
Config.get_with_origin("context.window_tokens")
|
|
51
|
+
end
|
|
52
|
+
rescue StandardError
|
|
53
|
+
[nil, :default]
|
|
54
|
+
end
|
|
55
|
+
return Resolved.new(tokens: DEFAULT_TOKENS, source: :default) unless positive_integer?(value)
|
|
56
|
+
|
|
57
|
+
Resolved.new(tokens: value, source: origin == :env ? :env : :config)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# For callers with no client at hand (tool output guardrails): the last
|
|
61
|
+
# window a server reported in this process, else #configured. With
|
|
62
|
+
# several hosts in one process this can be another host's window, which
|
|
63
|
+
# is good enough for a size heuristic.
|
|
64
|
+
def current
|
|
65
|
+
@last_server || configured
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset!
|
|
69
|
+
@last_server = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def positive_integer?(value)
|
|
73
|
+
value.is_a?(Integer) && value.positive?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The debug log file (Log's writer). One #write per record, so records
|
|
7
|
+
# from concurrent processes appending to the same file (O_APPEND) don't
|
|
8
|
+
# interleave. Failures never break the caller: an IO error (a full disk,
|
|
9
|
+
# a lost permission) pauses writing for RETRY_SECONDS, then it tries again.
|
|
10
|
+
#
|
|
11
|
+
# Rotation: a file over MAX_BYTES becomes <file>.1 (one kept), whichever
|
|
12
|
+
# process notices first. It takes <file>.lock without waiting (busy: the
|
|
13
|
+
# next write checks again) and looks again under it, so two processes
|
|
14
|
+
# never both rotate. Every write stats the path: a writer whose file was
|
|
15
|
+
# rotated away (another inode) reopens the new one.
|
|
16
|
+
class DebugLog
|
|
17
|
+
RETRY_SECONDS = 60
|
|
18
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
attr_reader :path
|
|
21
|
+
|
|
22
|
+
def initialize(path:, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, max_bytes: MAX_BYTES)
|
|
23
|
+
@path = path.nil? || path.to_s.strip.empty? ? nil : path.to_s
|
|
24
|
+
@clock = clock
|
|
25
|
+
@max_bytes = max_bytes
|
|
26
|
+
@io = nil
|
|
27
|
+
@ino = nil
|
|
28
|
+
@paused_until = nil
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
!@path.nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param record [String] one formatted record, newline-terminated
|
|
37
|
+
# @return [Boolean] whether it was written
|
|
38
|
+
def write(record)
|
|
39
|
+
return false unless @path
|
|
40
|
+
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
return false if @paused_until && @clock.call < @paused_until
|
|
43
|
+
|
|
44
|
+
@paused_until = nil
|
|
45
|
+
begin
|
|
46
|
+
ensure_io
|
|
47
|
+
follow_rotation
|
|
48
|
+
ensure_io.write(record)
|
|
49
|
+
true
|
|
50
|
+
rescue StandardError
|
|
51
|
+
pause
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def close
|
|
58
|
+
@mutex.synchronize { close_io }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def ensure_io
|
|
64
|
+
return @io if @io
|
|
65
|
+
|
|
66
|
+
dir = File.dirname(@path)
|
|
67
|
+
FileUtils.mkdir_p(dir) unless dir.empty?
|
|
68
|
+
@io = File.open(@path, File::WRONLY | File::APPEND | File::CREAT)
|
|
69
|
+
@io.sync = true
|
|
70
|
+
@ino = @io.stat.ino
|
|
71
|
+
@io
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Reopen when the path is another file now (rotated by someone else, or
|
|
75
|
+
# deleted); rotate when it grew over the cap. Regular files only: a
|
|
76
|
+
# FIFO or /dev/stderr is left alone.
|
|
77
|
+
def follow_rotation
|
|
78
|
+
stat = File.stat(@path)
|
|
79
|
+
return close_io unless stat.ino == @ino
|
|
80
|
+
return unless stat.file? && stat.size >= @max_bytes
|
|
81
|
+
|
|
82
|
+
rotate
|
|
83
|
+
rescue Errno::ENOENT
|
|
84
|
+
close_io
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def rotate
|
|
88
|
+
File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
89
|
+
return unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
90
|
+
|
|
91
|
+
stat = File.stat(@path)
|
|
92
|
+
File.rename(@path, "#{@path}.1") if stat.ino == @ino && stat.size >= @max_bytes
|
|
93
|
+
close_io
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def pause
|
|
98
|
+
@paused_until = @clock.call + RETRY_SECONDS
|
|
99
|
+
close_io
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def close_io
|
|
103
|
+
@io&.close
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
ensure
|
|
107
|
+
@io = nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Chi Helper: the "Send to chi" Service opens the panel with the selection,
|
|
2
|
+
// the hotkey with the clipboard. No Dock icon (LSUIElement); it stays
|
|
3
|
+
// running after the first launch. `ChiHelper --login on|off|status` (run
|
|
4
|
+
// directly by chi desktop) manages the login item and exits.
|
|
5
|
+
import AppKit
|
|
6
|
+
import ServiceManagement
|
|
7
|
+
|
|
8
|
+
final class ServiceProvider: NSObject {
|
|
9
|
+
weak var app: AppDelegate?
|
|
10
|
+
|
|
11
|
+
// Info.plist NSServices: NSMessage = sendToChi.
|
|
12
|
+
@objc func sendToChi(_ pboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString>) {
|
|
13
|
+
guard let text = pboard.string(forType: .string) else {
|
|
14
|
+
error.pointee = "No text in the selection" as NSString
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
app?.open(text: text)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
22
|
+
let panel = PanelController()
|
|
23
|
+
let provider = ServiceProvider()
|
|
24
|
+
private var hotkey: Hotkey?
|
|
25
|
+
/// A Service activates this app before the handler runs, so the app the
|
|
26
|
+
/// text came from is the last other app that was frontmost.
|
|
27
|
+
private var lastOtherApp: NSRunningApplication?
|
|
28
|
+
|
|
29
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
30
|
+
provider.app = self
|
|
31
|
+
NSApp.servicesProvider = provider
|
|
32
|
+
NSUpdateDynamicServices()
|
|
33
|
+
hotkey = Hotkey { [weak self] in self?.openFromClipboard() }
|
|
34
|
+
hotkey?.register()
|
|
35
|
+
|
|
36
|
+
lastOtherApp = otherApp(NSWorkspace.shared.frontmostApplication)
|
|
37
|
+
NSWorkspace.shared.notificationCenter.addObserver(
|
|
38
|
+
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
|
|
39
|
+
) { [weak self] note in
|
|
40
|
+
if let app = self?.otherApp(note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication) {
|
|
41
|
+
self?.lastOtherApp = app
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private func otherApp(_ app: NSRunningApplication?) -> NSRunningApplication? {
|
|
47
|
+
app?.processIdentifier == getpid() ? nil : app
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The app the text comes from: frontmost unless that is us.
|
|
51
|
+
var sourceApp: NSRunningApplication? {
|
|
52
|
+
otherApp(NSWorkspace.shared.frontmostApplication) ?? lastOtherApp
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The hotkey doesn't activate us, so the frontmost app is the source.
|
|
56
|
+
func openFromClipboard() {
|
|
57
|
+
open(text: NSPasteboard.general.string(forType: .string) ?? "")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func open(text: String) {
|
|
61
|
+
let from = sourceApp
|
|
62
|
+
panel.show(text: text, source: from?.localizedName?.lowercased() ?? "desktop", returnTo: from)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// SMAppService only lets an app register itself, so chi desktop runs the
|
|
67
|
+
/// binary with --login. Prints the status name; exit 1 on failure.
|
|
68
|
+
func loginCommand(_ arg: String) -> Int32 {
|
|
69
|
+
let service = SMAppService.mainApp
|
|
70
|
+
do {
|
|
71
|
+
switch arg {
|
|
72
|
+
case "on": if service.status != .enabled { try service.register() }
|
|
73
|
+
case "off": if service.status == .enabled || service.status == .requiresApproval { try service.unregister() }
|
|
74
|
+
case "status": break
|
|
75
|
+
default:
|
|
76
|
+
FileHandle.standardError.write("usage: ChiHelper --login on|off|status\n".data(using: .utf8)!)
|
|
77
|
+
return 2
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
FileHandle.standardError.write("\(error.localizedDescription)\n".data(using: .utf8)!)
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
let names: [SMAppService.Status: String] = [.notRegistered: "notRegistered", .enabled: "enabled",
|
|
84
|
+
.requiresApproval: "requiresApproval", .notFound: "notFound"]
|
|
85
|
+
print(names[service.status] ?? "unknown")
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@main
|
|
90
|
+
struct ChiHelperMain {
|
|
91
|
+
static func main() {
|
|
92
|
+
let args = CommandLine.arguments
|
|
93
|
+
if args.count >= 2, args[1] == "--login" {
|
|
94
|
+
exit(loginCommand(args.count >= 3 ? args[2] : ""))
|
|
95
|
+
}
|
|
96
|
+
let app = NSApplication.shared
|
|
97
|
+
let delegate = AppDelegate()
|
|
98
|
+
app.delegate = delegate
|
|
99
|
+
app.setActivationPolicy(.accessory)
|
|
100
|
+
app.run()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Runs chi the way `chi desktop install` wrote down in launch.json: an
|
|
2
|
+
// absolute ruby + bin/chi and a few env vars, since an app started by
|
|
3
|
+
// launchd has none of the shell's setup. Talks to chi only through its CLI.
|
|
4
|
+
import Foundation
|
|
5
|
+
|
|
6
|
+
struct LaunchConfig: Decodable {
|
|
7
|
+
let version: String?
|
|
8
|
+
let argv: [String]
|
|
9
|
+
let env: [String: String]?
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
struct ChiResult {
|
|
13
|
+
let status: Int32
|
|
14
|
+
let stdout: String
|
|
15
|
+
let stderr: String
|
|
16
|
+
let timedOut: Bool
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
enum ChiError: Error, CustomStringConvertible {
|
|
20
|
+
case noLaunchFile(String)
|
|
21
|
+
case missing(String)
|
|
22
|
+
case failed(String)
|
|
23
|
+
|
|
24
|
+
var description: String {
|
|
25
|
+
switch self {
|
|
26
|
+
case .noLaunchFile(let path): return "No launch file at \(path). Run `chi desktop upgrade` in a terminal."
|
|
27
|
+
case .missing(let path): return "chi not found at \(path). Run `chi desktop upgrade` in a terminal."
|
|
28
|
+
case .failed(let message): return message
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// One session from `chi sessions list --format json`: a live one, or a
|
|
34
|
+
/// recent stopped one (listed below the live ones; a message wakes it).
|
|
35
|
+
struct LiveSession: Decodable, Identifiable, Equatable {
|
|
36
|
+
let id: String
|
|
37
|
+
let desc: String?
|
|
38
|
+
let cwd: String?
|
|
39
|
+
let busy: Bool?
|
|
40
|
+
let owner: String?
|
|
41
|
+
let updatedAt: String?
|
|
42
|
+
var recent = false
|
|
43
|
+
|
|
44
|
+
enum CodingKeys: String, CodingKey {
|
|
45
|
+
case id, desc, cwd, busy, owner
|
|
46
|
+
case updatedAt = "updated_at"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Only UUID-shaped ids go into chi's argv, so nothing can turn into a flag.
|
|
50
|
+
var valid: Bool { UUID(uuidString: id) != nil }
|
|
51
|
+
|
|
52
|
+
var shortCwd: String {
|
|
53
|
+
guard let cwd else { return "" }
|
|
54
|
+
let home = NSHomeDirectory()
|
|
55
|
+
return cwd.hasPrefix(home) ? "~" + cwd.dropFirst(home.count) : cwd
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// "just now", "5m ago", "2h ago", "yesterday", "3d ago", or "".
|
|
59
|
+
func age(now: Date = Date()) -> String {
|
|
60
|
+
guard let updatedAt else { return "" }
|
|
61
|
+
let precise = ISO8601DateFormatter()
|
|
62
|
+
precise.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
63
|
+
guard let date = precise.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt) else { return "" }
|
|
64
|
+
let seconds = max(0, now.timeIntervalSince(date))
|
|
65
|
+
switch seconds {
|
|
66
|
+
case ..<60: return "just now"
|
|
67
|
+
case ..<3600: return "\(Int(seconds / 60))m ago"
|
|
68
|
+
case ..<86_400: return "\(Int(seconds / 3600))h ago"
|
|
69
|
+
case ..<172_800: return "yesterday"
|
|
70
|
+
default: return "\(Int(seconds / 86_400))d ago"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
final class ChiRunner {
|
|
76
|
+
static let supportDir = NSString(string: "~/Library/Application Support/Chi Helper").expandingTildeInPath
|
|
77
|
+
static let launchPath = supportDir + "/launch.json"
|
|
78
|
+
|
|
79
|
+
let timeout: TimeInterval
|
|
80
|
+
|
|
81
|
+
init(timeout: TimeInterval = 10) {
|
|
82
|
+
self.timeout = timeout
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func loadLaunch() throws -> LaunchConfig {
|
|
86
|
+
guard let data = FileManager.default.contents(atPath: Self.launchPath) else {
|
|
87
|
+
throw ChiError.noLaunchFile(Self.launchPath)
|
|
88
|
+
}
|
|
89
|
+
let config: LaunchConfig
|
|
90
|
+
do { config = try JSONDecoder().decode(LaunchConfig.self, from: data) } catch {
|
|
91
|
+
throw ChiError.failed("Unreadable launch file \(Self.launchPath): \(error.localizedDescription)")
|
|
92
|
+
}
|
|
93
|
+
guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(Self.launchPath)") }
|
|
94
|
+
for path in config.argv where path.hasPrefix("/") && !FileManager.default.fileExists(atPath: path) {
|
|
95
|
+
throw ChiError.missing(path)
|
|
96
|
+
}
|
|
97
|
+
return config
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Runs `chi <args>`, with stdin written from a background queue and
|
|
101
|
+
/// stdout/stderr drained as they come (no pipe deadlock near the 16 KiB
|
|
102
|
+
/// note cap). Calls back on the main queue.
|
|
103
|
+
func run(_ args: [String], stdin: Data? = nil, completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
104
|
+
let config: LaunchConfig
|
|
105
|
+
do { config = try loadLaunch() } catch let error as ChiError {
|
|
106
|
+
completion(.failure(error)); return
|
|
107
|
+
} catch {
|
|
108
|
+
completion(.failure(.failed("\(error)"))); return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let process = Process()
|
|
112
|
+
process.executableURL = URL(fileURLWithPath: config.argv[0])
|
|
113
|
+
process.arguments = Array(config.argv.dropFirst()) + args
|
|
114
|
+
process.environment = ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked }
|
|
115
|
+
process.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory())
|
|
116
|
+
let out = Pipe(), err = Pipe(), input = Pipe()
|
|
117
|
+
process.standardOutput = out
|
|
118
|
+
process.standardError = err
|
|
119
|
+
process.standardInput = stdin == nil ? FileHandle.nullDevice : input
|
|
120
|
+
|
|
121
|
+
let lock = NSLock()
|
|
122
|
+
var outData = Data(), errData = Data()
|
|
123
|
+
out.fileHandleForReading.readabilityHandler = { handle in
|
|
124
|
+
let chunk = handle.availableData
|
|
125
|
+
lock.lock(); outData.append(chunk); lock.unlock()
|
|
126
|
+
}
|
|
127
|
+
err.fileHandleForReading.readabilityHandler = { handle in
|
|
128
|
+
let chunk = handle.availableData
|
|
129
|
+
lock.lock(); errData.append(chunk); lock.unlock()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
var timedOut = false
|
|
133
|
+
process.terminationHandler = { proc in
|
|
134
|
+
out.fileHandleForReading.readabilityHandler = nil
|
|
135
|
+
err.fileHandleForReading.readabilityHandler = nil
|
|
136
|
+
let restOut = out.fileHandleForReading.readDataToEndOfFile()
|
|
137
|
+
let restErr = err.fileHandleForReading.readDataToEndOfFile()
|
|
138
|
+
lock.lock()
|
|
139
|
+
outData.append(restOut); errData.append(restErr)
|
|
140
|
+
let result = ChiResult(status: proc.terminationStatus,
|
|
141
|
+
stdout: String(decoding: outData, as: UTF8.self),
|
|
142
|
+
stderr: String(decoding: errData, as: UTF8.self),
|
|
143
|
+
timedOut: timedOut)
|
|
144
|
+
lock.unlock()
|
|
145
|
+
DispatchQueue.main.async { completion(.success(result)) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
do { try process.run() } catch {
|
|
149
|
+
completion(.failure(.failed("Could not start \(config.argv[0]): \(error.localizedDescription)")))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if let stdin {
|
|
153
|
+
DispatchQueue.global().async {
|
|
154
|
+
try? input.fileHandleForWriting.write(contentsOf: stdin)
|
|
155
|
+
try? input.fileHandleForWriting.close()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
|
159
|
+
if process.isRunning {
|
|
160
|
+
lock.lock(); timedOut = true; lock.unlock()
|
|
161
|
+
process.terminate()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// The live sessions, then up to +recentCount+ stopped ones, newest
|
|
167
|
+
/// first. A session a chi REPL holds is in neither: it takes no notes or
|
|
168
|
+
/// messages. A failed second call just leaves "recent" empty.
|
|
169
|
+
func sessions(recentCount: Int = 3, completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
170
|
+
list(["--live"]) { result in
|
|
171
|
+
guard case .success(let live) = result else { completion(result); return }
|
|
172
|
+
self.list(["--limit", "20"]) { recent in
|
|
173
|
+
let liveIds = Set(live.map(\.id))
|
|
174
|
+
let stopped = ((try? recent.get()) ?? [])
|
|
175
|
+
.filter { $0.owner == nil && !liveIds.contains($0.id) }
|
|
176
|
+
.prefix(recentCount)
|
|
177
|
+
.map { session -> LiveSession in var session = session; session.recent = true; return session }
|
|
178
|
+
completion(.success(live + stopped))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
184
|
+
// Every project's sessions: the helper isn't in any one project (an
|
|
185
|
+
// older chi ignores the flag).
|
|
186
|
+
run(["sessions", "list"] + flags + ["--scope=all", "--format", "json"]) { result in
|
|
187
|
+
switch result {
|
|
188
|
+
case .failure(let error): completion(.failure(error))
|
|
189
|
+
case .success(let r):
|
|
190
|
+
if r.timedOut { completion(.failure(.failed("chi sessions list took over \(Int(self.timeout)) s"))); return }
|
|
191
|
+
guard r.status == 0, let data = r.stdout.data(using: .utf8),
|
|
192
|
+
let sessions = try? JSONDecoder().decode([LiveSession].self, from: data) else {
|
|
193
|
+
let detail = r.stderr.isEmpty ? r.stdout : r.stderr
|
|
194
|
+
completion(.failure(.failed("chi sessions list failed (exit \(r.status)): \(detail.prefix(400))")))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
completion(.success(sessions.filter(\.valid)))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|