samagotchi 0.153.0 → 0.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +2 -3
- data/bin/chi +23 -24
- data/docs/architecture.md +30 -33
- data/docs/broadcast.md +5 -5
- data/docs/bundles.md +9 -12
- data/docs/cli.md +80 -96
- data/docs/configuration.md +27 -26
- data/docs/context.md +3 -3
- data/docs/desktop.md +1 -1
- data/docs/guardrails.md +8 -10
- data/docs/hooks.md +9 -9
- data/docs/internals/attached-context.md +2 -2
- data/docs/internals/background-tasks.md +3 -4
- data/docs/internals/block-renderers.md +4 -0
- data/docs/internals/bridge.md +3 -3
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +33 -7
- data/docs/internals/objectives.md +6 -5
- data/docs/internals/participants.md +7 -7
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-push.md +1 -1
- data/docs/memory.md +2 -2
- data/docs/plugins.md +23 -28
- data/docs/sessions.md +24 -24
- data/docs/sub-agent.md +2 -2
- data/docs/system-one.md +1 -1
- data/docs/testing.md +16 -6
- data/lib/samagotchi/answer_text.rb +34 -0
- data/lib/samagotchi/bridge.rb +43 -32
- data/lib/samagotchi/bridge_client.rb +10 -9
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/broadcast/triage.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +5 -5
- data/lib/samagotchi/bundle_command/try/command.rb +1 -1
- data/lib/samagotchi/bundle_command/try/run.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
- data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/child_move.rb +3 -3
- data/lib/samagotchi/commands/registry.rb +6 -6
- data/lib/samagotchi/composer_route.rb +4 -5
- data/lib/samagotchi/config.rb +14 -12
- data/lib/samagotchi/context_candidate_choice.rb +1 -1
- data/lib/samagotchi/context_candidates.rb +4 -3
- data/lib/samagotchi/continue_offer.rb +1 -1
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/degrade.rb +75 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
- data/lib/samagotchi/engine/cards.rb +87 -0
- data/lib/samagotchi/engine/context_judge.rb +138 -0
- data/lib/samagotchi/engine/events.rb +131 -0
- data/lib/samagotchi/engine/guardrails.rb +87 -0
- data/lib/samagotchi/engine/hooks.rb +119 -0
- data/lib/samagotchi/engine/messages.rb +124 -0
- data/lib/samagotchi/engine/model_settings.rb +127 -0
- data/lib/samagotchi/engine/models.rb +261 -0
- data/lib/samagotchi/engine/objectives.rb +116 -0
- data/lib/samagotchi/engine/participants.rb +86 -0
- data/lib/samagotchi/engine/plugins.rb +208 -0
- data/lib/samagotchi/engine/questions.rb +98 -0
- data/lib/samagotchi/engine/recap.rb +130 -0
- data/lib/samagotchi/engine/session.rb +131 -0
- data/lib/samagotchi/engine/side_requests.rb +135 -0
- data/lib/samagotchi/engine/steering.rb +134 -0
- data/lib/samagotchi/engine/toolkit.rb +269 -0
- data/lib/samagotchi/engine/turn_end.rb +180 -0
- data/lib/samagotchi/engine/turn_helpers.rb +160 -0
- data/lib/samagotchi/engine/verify.rb +153 -0
- data/lib/samagotchi/engine.rb +62 -2646
- data/lib/samagotchi/grants.rb +575 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -2
- data/lib/samagotchi/guardrails/context.rb +2 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
- data/lib/samagotchi/hook_routes.rb +3 -3
- data/lib/samagotchi/idle_recap.rb +4 -5
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +10 -11
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm_context_command.rb +3 -2
- data/lib/samagotchi/log.rb +7 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_path.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +18 -8
- data/lib/samagotchi/objectives.rb +24 -3
- data/lib/samagotchi/objectives_check.rb +8 -27
- data/lib/samagotchi/objectives_command.rb +4 -69
- data/lib/samagotchi/objectives_edit.rb +87 -0
- data/lib/samagotchi/output_formatter.rb +7 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/parent_answers_command.rb +5 -4
- data/lib/samagotchi/participant_exposure.rb +42 -0
- data/lib/samagotchi/participant_mode.rb +1 -1
- data/lib/samagotchi/participant_name.rb +1 -1
- data/lib/samagotchi/participants.rb +17 -17
- data/lib/samagotchi/participants_watch.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/context.rb +21 -7
- data/lib/samagotchi/plugin/peer.rb +2 -1
- data/lib/samagotchi/plugin/sessions.rb +6 -4
- data/lib/samagotchi/plugin_tasks.rb +1 -2
- data/lib/samagotchi/private_files.rb +29 -0
- data/lib/samagotchi/project_env.rb +2 -2
- data/lib/samagotchi/question_desk.rb +8 -7
- data/lib/samagotchi/recap_command.rb +2 -1
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/recap_store.rb +1 -1
- data/lib/samagotchi/redact.rb +86 -0
- data/lib/samagotchi/reminder_queue.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +2 -2
- data/lib/samagotchi/review_start.rb +1 -1
- data/lib/samagotchi/run_options.rb +6 -6
- data/lib/samagotchi/self_report.rb +42 -39
- data/lib/samagotchi/send_command/busy.rb +1 -1
- data/lib/samagotchi/send_command.rb +4 -1
- data/lib/samagotchi/session.rb +11 -2
- data/lib/samagotchi/session_access/contract.rb +3 -3
- data/lib/samagotchi/session_access/local.rb +9 -9
- data/lib/samagotchi/session_access/remote.rb +17 -7
- data/lib/samagotchi/session_access.rb +4 -4
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +8 -6
- data/lib/samagotchi/session_commands.rb +45 -48
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +4 -4
- data/lib/samagotchi/session_manager.rb +59 -36
- data/lib/samagotchi/session_metrics.rb +2 -2
- data/lib/samagotchi/session_model_settings.rb +3 -2
- data/lib/samagotchi/session_participants.rb +1 -1
- data/lib/samagotchi/session_retention.rb +3 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/system_prompt.rb +102 -109
- data/lib/samagotchi/task_watch.rb +1 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
- data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
- data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
- data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
- data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
- data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +14 -16
- data/lib/samagotchi/toolkit_command.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +3 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/objectives_tool.rb +2 -2
- data/lib/samagotchi/tools/send_note.rb +2 -1
- data/lib/samagotchi/turn_endings.rb +2 -2
- data/lib/samagotchi/turn_flow.rb +4 -5
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +39 -50
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_review.rb +5 -5
- data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
- data/lib/samagotchi/web/participant_view.rb +2 -82
- data/lib/samagotchi/web/participants_admin.rb +1 -1
- data/lib/samagotchi/web/public/block_host.js +35 -8
- data/lib/samagotchi/web/public/block_renderers.css +9 -5
- data/lib/samagotchi/web/public/chains.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -3
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +12 -8
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +2 -2
- data/lib/samagotchi/web/session_show.rb +2 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +100 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- metadata +29 -6
- data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
- data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
- data/lib/samagotchi/web/grants.rb +0 -575
- data/lib/samagotchi/web/participant_exposure.rb +0 -44
- data/lib/samagotchi/web/private_files.rb +0 -31
|
@@ -9,7 +9,7 @@ module Samagotchi
|
|
|
9
9
|
# `/title --clear` drops it, and the lists show the first message again.
|
|
10
10
|
# Anyone who may send the session a command may name it (a parent agent
|
|
11
11
|
# its delegates too). It doesn't save: its host saves a changed result at
|
|
12
|
-
# once (Worker
|
|
12
|
+
# once (Worker). It runs mid-turn as well (anytime): the
|
|
13
13
|
# worker's save takes the session's lock and writes a running turn's
|
|
14
14
|
# checkpoint, as a turn's own saves do.
|
|
15
15
|
class TitleCommand
|
|
@@ -28,7 +28,7 @@ module Samagotchi
|
|
|
28
28
|
# @return [Array(String, Boolean, Symbol)] the reply, whether the
|
|
29
29
|
# title changed, and :ok or :error
|
|
30
30
|
def run(args)
|
|
31
|
-
session = @engine.session or return [
|
|
31
|
+
session = @engine.session or return [Session::NONE_YET, false, :error]
|
|
32
32
|
text = args.to_s.gsub(/\s+/, " ").strip
|
|
33
33
|
return [listing(session), false, :ok] if text.empty?
|
|
34
34
|
# "--clear and more" is a slip, not a title.
|
|
@@ -6,6 +6,7 @@ require_relative "row_refs"
|
|
|
6
6
|
require_relative "file_ref"
|
|
7
7
|
require_relative "guardrails"
|
|
8
8
|
require_relative "vision_context"
|
|
9
|
+
require_relative "degrade"
|
|
9
10
|
require_relative "log"
|
|
10
11
|
require_relative "edit_preview"
|
|
11
12
|
require_relative "tool_output_cut"
|
|
@@ -234,12 +235,12 @@ module Samagotchi
|
|
|
234
235
|
# default isn't a window) or can't be read. It checks this result, not the
|
|
235
236
|
# context already used.
|
|
236
237
|
def window_guard
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
Degrade.call(:memory, "read_window_unknown") do
|
|
239
|
+
window = @window ? @window.call : (@kernel.tool_output_window if @kernel.respond_to?(:tool_output_window))
|
|
240
|
+
return nil unless window&.tokens&.positive? && window.source != :default
|
|
239
241
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
nil
|
|
242
|
+
(window.tokens * MemoryBundle::IndexSize.chars_per_token / 2).floor
|
|
243
|
+
end
|
|
243
244
|
end
|
|
244
245
|
|
|
245
246
|
# memory_read's cut line: the rest is in the memory's file, in the dir(s)
|
|
@@ -307,12 +308,12 @@ module Samagotchi
|
|
|
307
308
|
# (MemoryBundle::IndexSize), nil for any other path, or when
|
|
308
309
|
# memory.index_warn_tokens is off: other writes cost nothing.
|
|
309
310
|
def memory_index_size(call)
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
Degrade.call(:memory, "index_size_unreadable") do
|
|
312
|
+
scope = MemoryBundle::IndexSync.memory_scope(Tools::ToolPath.normalize(call[:path]))
|
|
313
|
+
return nil unless scope && MemoryBundle::IndexSize.warn_limit.positive?
|
|
312
314
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
nil
|
|
315
|
+
MemoryBundle::IndexSize.measure(scope)
|
|
316
|
+
end
|
|
316
317
|
end
|
|
317
318
|
|
|
318
319
|
# +result+ with the note memory_write gives when the refreshed index
|
|
@@ -556,15 +557,12 @@ module Samagotchi
|
|
|
556
557
|
|
|
557
558
|
# A failing hook must not break the turn.
|
|
558
559
|
def fire(name, event)
|
|
559
|
-
@kernel.hooks&.fire(name, event)
|
|
560
|
-
rescue StandardError
|
|
561
|
-
nil
|
|
560
|
+
Degrade.call(:hooks, "tool_hook_fire_failed", hook: name) { @kernel.hooks&.fire(name, event) }
|
|
562
561
|
end
|
|
563
562
|
|
|
563
|
+
# A failing stream callback (a UI gone) must not break the call either.
|
|
564
564
|
def emit(callback, event)
|
|
565
|
-
callback&.call(event)
|
|
566
|
-
rescue StandardError
|
|
567
|
-
nil
|
|
565
|
+
Degrade.call(:turn, "tool_event_callback_failed", type: (event[:type] if event.is_a?(Hash))) { callback&.call(event) }
|
|
568
566
|
end
|
|
569
567
|
end
|
|
570
568
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "cache_confirm"
|
|
5
5
|
require_relative "toolkit"
|
|
6
|
+
require_relative "session"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# /toolkit, the session's toolkit (Session#toolkit): with no argument,
|
|
@@ -40,7 +41,7 @@ module Samagotchi
|
|
|
40
41
|
words, yes = CacheConfirm.split(args, downcase: false)
|
|
41
42
|
return [listing(@engine.toolkit_explained), false, nil] if words.empty?
|
|
42
43
|
|
|
43
|
-
session = @engine.session or raise ArgumentError,
|
|
44
|
+
session = @engine.session or raise ArgumentError, Session::NONE_YET
|
|
44
45
|
before = @engine.toolkit_explained
|
|
45
46
|
after = @engine.toolkit_explained(changed(before.toolkit, words.split))
|
|
46
47
|
# Nothing the prompt carries changes (full +delegate on full): only
|
|
@@ -336,8 +336,10 @@ module Samagotchi
|
|
|
336
336
|
when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
|
|
337
337
|
else "Error: the message to session #{id[0, 8]} could not be written"
|
|
338
338
|
end
|
|
339
|
+
rescue SessionManager::ScratchSession => e
|
|
340
|
+
"Error: session #{id[0, 8]}: #{e.message}"
|
|
339
341
|
rescue SessionManager::OwnedByTUI
|
|
340
|
-
"Error: session #{id[0, 8]} is
|
|
342
|
+
"Error: session #{id[0, 8]} is #{OwnerLock::TUI_BUSY}, which can't take a delegated message"
|
|
341
343
|
rescue Session::AmbiguousId => e
|
|
342
344
|
"Error: #{e.message}"
|
|
343
345
|
end
|
|
@@ -67,9 +67,9 @@ module Samagotchi
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
# The parent's cursors, one per child, in <parent dir>/delegates.json, so
|
|
70
|
-
# a respawned worker (or a
|
|
71
|
-
# model a reply it already had. Written by whoever owns the parent
|
|
72
|
-
# worker, or the
|
|
70
|
+
# a respawned worker (or a one-shot resuming the session) doesn't hand
|
|
71
|
+
# the model a reply it already had. Written by whoever owns the parent
|
|
72
|
+
# (its worker, or the one-shot its kernel runs in), and by a continue moving
|
|
73
73
|
# delegates to the chain's new link (ChildMove, from another process):
|
|
74
74
|
# a Mutex for the owner's turn thread vs its loop thread, and a flock on
|
|
75
75
|
# LOCK_FILE between processes; the file is replaced whole.
|
|
@@ -143,7 +143,7 @@ module Samagotchi
|
|
|
143
143
|
# "wake" (a delegate report, a turn of its own when idle), "queue" (a
|
|
144
144
|
# report at its next step or turn, no wake) or "off" (only
|
|
145
145
|
# delegate_result). Only a worker's session reads its children's rings
|
|
146
|
-
# (its Engine gives a relay); a
|
|
146
|
+
# (its Engine gives a relay); a -p --non-interactive run or a scratch session
|
|
147
147
|
# never gets a report.
|
|
148
148
|
# @param peers [Peers]
|
|
149
149
|
# @return [String] "wake", "queue" or "off"
|
|
@@ -64,8 +64,8 @@ module Samagotchi
|
|
|
64
64
|
|
|
65
65
|
def self.refusal(list, id, call)
|
|
66
66
|
item = list.find(id)
|
|
67
|
-
return "Error:
|
|
68
|
-
return "Error: #{id}
|
|
67
|
+
return "Error: #{Objectives.unknown(id, list)}. Nothing was recorded." unless item
|
|
68
|
+
return "Error: #{Objectives.dropped(id)}. Nothing was recorded." if item.status == Objectives::DROPPED
|
|
69
69
|
|
|
70
70
|
"Error: a move needs a note: what moved, in a line." if Objectives.clean(call[:note]).empty?
|
|
71
71
|
end
|
|
@@ -28,7 +28,8 @@ module Samagotchi
|
|
|
28
28
|
|
|
29
29
|
result = NoteDelivery.deliver(id, text: content, source: "session", from_session: peers.session_id,
|
|
30
30
|
from_cwd: peers.cwd, state_dir: peers.state_dir)
|
|
31
|
-
return "Error: session #{id[0, 8]}
|
|
31
|
+
return "Error: session #{id[0, 8]}: #{result.refusal}" if result.scratch
|
|
32
|
+
return "Error: session #{id[0, 8]} is #{OwnerLock::TUI_BUSY}, which can't take notes" unless result.delivered?
|
|
32
33
|
|
|
33
34
|
where = result.status == :queued ? "its worker adds it before its next turn" : "it has no worker now, so it waits for its next start"
|
|
34
35
|
"Queued a note for session #{id[0, 8]}: #{where}. It does not start a turn there."
|
|
@@ -34,7 +34,7 @@ module Samagotchi
|
|
|
34
34
|
|
|
35
35
|
# What the session keeps of a turn that ended normally (nil: nothing to
|
|
36
36
|
# replace). Runs under the event lock: it also adds the turn's note to
|
|
37
|
-
# result.conversation, which the
|
|
37
|
+
# result.conversation, which the session keeps as-is.
|
|
38
38
|
# A turn that ran out of iterations ends at its tool results, so a
|
|
39
39
|
# continue resumes from them rather than after a made-up reply (and a
|
|
40
40
|
# step-limit Continue from its run's start: ModelResult#run_start).
|
|
@@ -43,7 +43,7 @@ module Samagotchi
|
|
|
43
43
|
# Nothing visible (no text) is a turn the model should know ended
|
|
44
44
|
# that way: the note, and nothing made up as its answer. The note's
|
|
45
45
|
# marker is for the UIs (the notice, and the empty steps after a
|
|
46
|
-
# reload); it goes on the result's conversation too (the
|
|
46
|
+
# reload); it goes on the result's conversation too (the session keeps it).
|
|
47
47
|
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
48
48
|
# turn's note stays: it says why that turn ended.
|
|
49
49
|
if result.empty_answer?
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -6,8 +6,8 @@ require_relative "context_note"
|
|
|
6
6
|
require_relative "llm/errors"
|
|
7
7
|
|
|
8
8
|
module Samagotchi
|
|
9
|
-
# The state around turns
|
|
10
|
-
#
|
|
9
|
+
# The state around turns a session worker (and a one-shot) keeps out of
|
|
10
|
+
# its loop: the pre-turn checkpoint (what !rollback and a failed turn
|
|
11
11
|
# restore), the continue offer after a turn that ran out of iterations, and
|
|
12
12
|
# the checkpoint a continue turn starts from.
|
|
13
13
|
#
|
|
@@ -189,9 +189,8 @@ module Samagotchi
|
|
|
189
189
|
end
|
|
190
190
|
|
|
191
191
|
# Restore the pre-turn checkpoint (!rollback after a Ctrl-C). A pending
|
|
192
|
-
# offer goes too: the turn it offered to continue is gone. (
|
|
193
|
-
#
|
|
194
|
-
# also takes !rollback then.) The cards' places follow the messages
|
|
192
|
+
# offer goes too: the turn it offered to continue is gone. (A worker
|
|
193
|
+
# takes !rollback while an offer is pending.) The cards' places follow the messages
|
|
195
194
|
# (Bridge::CardStore).
|
|
196
195
|
# @return [Boolean] false when there is none
|
|
197
196
|
def rollback!
|
|
@@ -7,8 +7,8 @@ require_relative "session"
|
|
|
7
7
|
require_relative "version"
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
|
-
# One line at the first interactive start of a new chi version (
|
|
11
|
-
#
|
|
10
|
+
# One line at the first interactive start of a new chi version (a
|
|
11
|
+
# terminal, chi web) when something chi update would update is behind:
|
|
12
12
|
# "chi 0.3.1: 2 bundles and the desktop helper can be updated: chi update".
|
|
13
13
|
# Noted once per version in $XDG_STATE_HOME/samagotchi/update_noted,
|
|
14
14
|
# whether or not anything was behind. No network; nothing is changed (the
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -12,6 +12,7 @@ require "rack/request"
|
|
|
12
12
|
|
|
13
13
|
require_relative "../analytics_file"
|
|
14
14
|
require_relative "../client_id"
|
|
15
|
+
require_relative "../degrade"
|
|
15
16
|
require_relative "../delivery"
|
|
16
17
|
require_relative "../answer_tail"
|
|
17
18
|
require_relative "../answer_refs"
|
|
@@ -969,11 +970,7 @@ module Samagotchi
|
|
|
969
970
|
|
|
970
971
|
root = dir && ProjectScope.root_for(dir)
|
|
971
972
|
# Lazy retention sweep (once per 24h)
|
|
972
|
-
|
|
973
|
-
@manager.retention_sweep_if_due(state_dir: @state_dir)
|
|
974
|
-
rescue StandardError
|
|
975
|
-
nil
|
|
976
|
-
end
|
|
973
|
+
Degrade.call(:web, "retention_sweep_failed") { @manager.retention_sweep_if_due(state_dir: @state_dir) }
|
|
977
974
|
limit = sanitize_limit(req.params["limit"])
|
|
978
975
|
offset = sanitize_offset(req.params["offset"])
|
|
979
976
|
all = @hub.snapshot(project_root: root, sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"]))
|
|
@@ -1056,8 +1053,8 @@ module Samagotchi
|
|
|
1056
1053
|
# A line the page sent, once the worker took it, into the shared prompt
|
|
1057
1054
|
# history; "history": false (an image-only send's placeholder text)
|
|
1058
1055
|
# keeps it out. A failed write never fails the send. There's no scratch
|
|
1059
|
-
# check: a
|
|
1060
|
-
#
|
|
1056
|
+
# check: a scratch session is refused before any delivery
|
|
1057
|
+
# (SessionManager::ScratchSession, an OwnedByTUI: 409).
|
|
1061
1058
|
def record_history(body, line)
|
|
1062
1059
|
return if body["history"] == false
|
|
1063
1060
|
|
|
@@ -1067,11 +1064,7 @@ module Samagotchi
|
|
|
1067
1064
|
end
|
|
1068
1065
|
|
|
1069
1066
|
def handle_models(_req)
|
|
1070
|
-
default =
|
|
1071
|
-
ModelProfile.required_model_name
|
|
1072
|
-
rescue StandardError
|
|
1073
|
-
nil
|
|
1074
|
-
end
|
|
1067
|
+
default = Degrade.call(:web, "default_model_unresolved") { ModelProfile.required_model_name }
|
|
1075
1068
|
models = []
|
|
1076
1069
|
warnings = []
|
|
1077
1070
|
begin
|
|
@@ -1156,35 +1149,35 @@ module Samagotchi
|
|
|
1156
1149
|
# and each model's LLM context (LLMContextStrategy::Explained#summary).
|
|
1157
1150
|
# A config that can't be read leaves the list as it is.
|
|
1158
1151
|
def add_sampling(models, registry)
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1152
|
+
Degrade.call(:web, "model_sampling_unreadable") do
|
|
1153
|
+
return if registry.nil? || models.empty?
|
|
1154
|
+
|
|
1155
|
+
settings = ConfigFile.model_settings
|
|
1156
|
+
models.each do |model|
|
|
1157
|
+
target = registry.resolve(model[:name])
|
|
1158
|
+
names = registry.lookup_names(model[:name], target: target)
|
|
1159
|
+
summary = SamplingSettings.summary(target, names: names, models: settings)
|
|
1160
|
+
model[:sampling] = summary if summary
|
|
1161
|
+
add_llm_context(model, target, names, settings)
|
|
1162
|
+
add_thinking(model, target, names, settings)
|
|
1163
|
+
end
|
|
1169
1164
|
end
|
|
1170
|
-
rescue StandardError
|
|
1171
|
-
nil
|
|
1172
1165
|
end
|
|
1173
1166
|
|
|
1174
1167
|
# The LLM context +model+ starts under (the start page's ⚙ panel);
|
|
1175
1168
|
# one that can't be read leaves this model without, not the others.
|
|
1176
1169
|
def add_llm_context(model, target, names, settings)
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1170
|
+
Degrade.call(:web, "model_llm_context_unreadable") do
|
|
1171
|
+
model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
|
|
1172
|
+
end
|
|
1180
1173
|
end
|
|
1181
1174
|
|
|
1182
1175
|
# The thinking level +model+ starts at, with where it came from (the
|
|
1183
1176
|
# start page's ⚙ panel; SettingExplained#summary, no session).
|
|
1184
1177
|
def add_thinking(model, target, names, settings)
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1178
|
+
Degrade.call(:web, "model_thinking_unreadable") do
|
|
1179
|
+
model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
|
|
1180
|
+
end
|
|
1188
1181
|
end
|
|
1189
1182
|
|
|
1190
1183
|
# The served model a session's last generation reported (analytics.json,
|
|
@@ -1193,25 +1186,25 @@ module Samagotchi
|
|
|
1193
1186
|
# Engine#served_model does, with the host whose served: expects it.
|
|
1194
1187
|
# {} when none applies.
|
|
1195
1188
|
def saved_served_model(session, persisted)
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1189
|
+
Degrade.call(:web, "served_model_unreadable", fallback: {}) do
|
|
1190
|
+
served = persisted["served_model"]
|
|
1191
|
+
model = session.model_name.to_s
|
|
1192
|
+
return {} unless served.is_a?(String) && !served.empty? && !model.empty?
|
|
1199
1193
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1194
|
+
registry = host_registry
|
|
1195
|
+
asked = registry.bare_name(model)
|
|
1196
|
+
return {} unless persisted["served_model_for"] == asked
|
|
1203
1197
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
{}
|
|
1198
|
+
{ served_model: served, served_model_for: asked, served_expected_by: saved_served_expected_by(registry, model, served) }
|
|
1199
|
+
end
|
|
1207
1200
|
end
|
|
1208
1201
|
|
|
1209
1202
|
# See ServedModel.expected_by; nil when the model doesn't resolve (the
|
|
1210
1203
|
# mismatch still shows).
|
|
1211
1204
|
def saved_served_expected_by(registry, model, served)
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1205
|
+
Degrade.call(:web, "served_expected_by_unresolved") do
|
|
1206
|
+
ServedModel.expected_by(registry.resolve(model), served)
|
|
1207
|
+
end
|
|
1215
1208
|
end
|
|
1216
1209
|
|
|
1217
1210
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
@@ -1343,9 +1336,9 @@ module Samagotchi
|
|
|
1343
1336
|
|
|
1344
1337
|
# The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
|
|
1345
1338
|
def session_owner(id)
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1339
|
+
Degrade.call(:web, "session_owner_unreadable") do
|
|
1340
|
+
@manager.session_owner(id, state_dir: @state_dir)
|
|
1341
|
+
end
|
|
1349
1342
|
end
|
|
1350
1343
|
|
|
1351
1344
|
# ── The working set (web.working_set) ───────────────────────────────
|
|
@@ -1871,11 +1864,7 @@ module Samagotchi
|
|
|
1871
1864
|
# Try ISO8601, then float timestamp
|
|
1872
1865
|
Time.iso8601(val.to_s)
|
|
1873
1866
|
rescue ArgumentError
|
|
1874
|
-
|
|
1875
|
-
Time.at(Float(val.to_s))
|
|
1876
|
-
rescue StandardError
|
|
1877
|
-
nil
|
|
1878
|
-
end
|
|
1867
|
+
Degrade.call(:web, "since_unparsable") { Time.at(Float(val.to_s)) }
|
|
1879
1868
|
end
|
|
1880
1869
|
|
|
1881
1870
|
def default_state_dir
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "grants"
|
|
4
|
+
require_relative "../grants"
|
|
5
5
|
require_relative "principal"
|
|
6
6
|
require_relative "participant_view"
|
|
7
7
|
require_relative "../archive_store"
|
|
@@ -19,7 +19,7 @@ require_relative "../log"
|
|
|
19
19
|
|
|
20
20
|
module Samagotchi
|
|
21
21
|
module Web
|
|
22
|
-
# What a participant (a
|
|
22
|
+
# What a participant (a Grants token) may do in chi web, mixed into
|
|
23
23
|
# App: the gate's lookup of their grant, the refusals that are theirs
|
|
24
24
|
# (token_revoked, token_expired with its ended page, token_bound for a
|
|
25
25
|
# link bound to another account, another session's
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "participant_view"
|
|
4
4
|
require_relative "token"
|
|
5
5
|
require_relative "participant_stream_filter"
|
|
6
|
-
require_relative "participant_exposure"
|
|
6
|
+
require_relative "../participant_exposure"
|
|
7
7
|
require_relative "../session_metrics"
|
|
8
8
|
require_relative "../review_start"
|
|
9
9
|
require_relative "../guardrails/review_paths"
|
|
@@ -44,7 +44,7 @@ module Samagotchi
|
|
|
44
44
|
# cookie value.
|
|
45
45
|
def participant_viewer(req, session)
|
|
46
46
|
author = principal(req).owner? ? nil : principal(req).grant.author
|
|
47
|
-
|
|
47
|
+
Redact::Viewer.new(author: author, cwd: session.working_directory, secrets: owner_secrets)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def owner_secrets
|
|
@@ -119,7 +119,7 @@ module Samagotchi
|
|
|
119
119
|
|
|
120
120
|
def review_subject(path, full, viewer)
|
|
121
121
|
raw = File.open(full, File::RDONLY | File::NOFOLLOW | File::BINARY) { |file| file.read(SUBJECT_BYTES + 1) }.to_s
|
|
122
|
-
text =
|
|
122
|
+
text = Redact.text(raw.byteslice(0, SUBJECT_BYTES).force_encoding(Encoding::UTF_8).scrub, viewer)
|
|
123
123
|
entry = { path: path, text: text, truncated: raw.bytesize > SUBJECT_BYTES }
|
|
124
124
|
markdown = MARKDOWN_EXT.include?(File.extname(path).downcase)
|
|
125
125
|
entry[:html] = @markdown_renderer.render(text) if markdown && @markdown_renderer.available?
|
|
@@ -226,7 +226,7 @@ module Samagotchi
|
|
|
226
226
|
return nil if ref.to_s.empty?
|
|
227
227
|
|
|
228
228
|
name = host_registry.bare_name(ref.to_s)
|
|
229
|
-
name.to_s.empty? ? nil :
|
|
229
|
+
name.to_s.empty? ? nil : Redact.text(name.to_s, viewer)
|
|
230
230
|
rescue StandardError
|
|
231
231
|
nil
|
|
232
232
|
end
|
|
@@ -235,7 +235,7 @@ module Samagotchi
|
|
|
235
235
|
# enqueued id, for dequeue, and the text), and how many others wait.
|
|
236
236
|
def review_queue(queued, viewer)
|
|
237
237
|
mine, others = queued.partition { |entry| entry.is_a?(Hash) && viewer.own?(entry["author"]) }
|
|
238
|
-
{ mine: mine.map { |entry|
|
|
238
|
+
{ mine: mine.map { |entry| Redact.deep({ "enqueued_id" => entry["enqueued_id"].to_s, "prompt" => entry["prompt"].to_s }, viewer) },
|
|
239
239
|
others: others.size }
|
|
240
240
|
end
|
|
241
241
|
|
|
@@ -134,14 +134,14 @@ module Samagotchi
|
|
|
134
134
|
def hard_cut(id, shown)
|
|
135
135
|
text = @held.sub(/\S*\z/, "")
|
|
136
136
|
# A key word or a key's BEGIN line before that run goes with it.
|
|
137
|
-
open =
|
|
137
|
+
open = Redact::OPEN_VALUE.match(text[-80..] || text)
|
|
138
138
|
text = text[0, text.size - open[0].size] if open
|
|
139
139
|
@dropped = +""
|
|
140
140
|
@dropped_key = false
|
|
141
141
|
dropping = @held[text.size..]
|
|
142
142
|
@held = +""
|
|
143
143
|
@held << undropped(dropping)
|
|
144
|
-
frame(id, shown.merge("text" => "#{lines_of(text)}#{
|
|
144
|
+
frame(id, shown.merge("text" => "#{lines_of(text)}#{Redact::MARK}"))
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
# The last newline (past MAX_HELD, the last space) the held text may
|
|
@@ -162,11 +162,11 @@ module Samagotchi
|
|
|
162
162
|
# a secret's value would go on.
|
|
163
163
|
def open_value?(at)
|
|
164
164
|
from = [at - 80, 0].max
|
|
165
|
-
|
|
165
|
+
Redact::OPEN_VALUE.match?(@held[from..at])
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
-
SPAN_PATTERNS = (
|
|
169
|
-
|
|
168
|
+
SPAN_PATTERNS = (Redact::PATTERNS - [Redact::PRIVATE_KEY] +
|
|
169
|
+
Redact::VALUES).freeze
|
|
170
170
|
|
|
171
171
|
# [from, to) of every match Redact would redact in +text+ (but a
|
|
172
172
|
# private key's block: lines_of withholds it by its lines).
|
|
@@ -220,15 +220,15 @@ module Samagotchi
|
|
|
220
220
|
if @in_key
|
|
221
221
|
@in_key = opens_key?(line) if PEM_END.match?(line)
|
|
222
222
|
elsif opens_key?(line)
|
|
223
|
-
out <<
|
|
223
|
+
out << Redact.text(run, @viewer)
|
|
224
224
|
run = +""
|
|
225
225
|
@in_key = true
|
|
226
|
-
out << "#{
|
|
226
|
+
out << "#{Redact::MARK}#{"\n" if line.end_with?("\n")}"
|
|
227
227
|
else
|
|
228
228
|
run << line
|
|
229
229
|
end
|
|
230
230
|
end
|
|
231
|
-
out <<
|
|
231
|
+
out << Redact.text(run, @viewer)
|
|
232
232
|
end
|
|
233
233
|
|
|
234
234
|
# Whether +line+ leaves a private key block open: a BEGIN line with
|
|
@@ -6,6 +6,7 @@ require_relative "../turn_origin"
|
|
|
6
6
|
require_relative "../participant_mode"
|
|
7
7
|
require_relative "../revise_anchor"
|
|
8
8
|
require_relative "../verify_anchor"
|
|
9
|
+
require_relative "../redact"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Web
|
|
@@ -23,23 +24,10 @@ module Samagotchi
|
|
|
23
24
|
# is an allowlist that builds a new hash from named fields, so a field
|
|
24
25
|
# added to an event later never reaches a participant until it is
|
|
25
26
|
# named here, and an event type not named is dropped. Every string
|
|
26
|
-
# that goes out passes Redact (secret shapes, the owner's token, the
|
|
27
|
+
# that goes out passes Redact (lib/samagotchi/redact.rb: secret shapes, the owner's token, the
|
|
27
28
|
# home folder), defence in depth only: the allowlist is the control.
|
|
28
29
|
# Pure: no I/O; keys of what comes in may be strings (JSON) or symbols.
|
|
29
30
|
module ParticipantView
|
|
30
|
-
# Who is looking: +author+ the participant's ("p:<id>"; nil for the
|
|
31
|
-
# owner through the participant view: nothing is theirs), +cwd+ the
|
|
32
|
-
# session's folder (tool paths are shown relative to it, only inside
|
|
33
|
-
# it), +home+ the folder shown as ~, +secrets+ literal strings never
|
|
34
|
-
# shown (the owner's token).
|
|
35
|
-
Viewer = Data.define(:author, :cwd, :home, :secrets) do
|
|
36
|
-
def initialize(author: nil, cwd: nil, home: Dir.home, secrets: [])
|
|
37
|
-
super(author: author, cwd: cwd, home: home, secrets: Array(secrets).map(&:to_s).reject(&:empty?))
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def own?(author) = !self.author.nil? && author.to_s == self.author
|
|
41
|
-
end
|
|
42
|
-
|
|
43
31
|
# A turn's participant_mode values the view passes on (ParticipantMode).
|
|
44
32
|
PARTICIPANT_MODES = [ParticipantMode::REVIEW.to_s, ParticipantMode::PAIR.to_s].freeze
|
|
45
33
|
|
|
@@ -431,74 +419,6 @@ module Samagotchi
|
|
|
431
419
|
|
|
432
420
|
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
433
421
|
end
|
|
434
|
-
|
|
435
|
-
# Secret shapes and host paths out of what a participant gets (the
|
|
436
|
-
# session's folder as relative paths, home as ~). Defence in depth: the
|
|
437
|
-
# allowlist above is the control; this catches a key the model quoted
|
|
438
|
-
# in a reply, or one in a prompt.
|
|
439
|
-
module Redact
|
|
440
|
-
MARK = "[redacted]"
|
|
441
|
-
PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)/m
|
|
442
|
-
PATTERNS = [
|
|
443
|
-
PRIVATE_KEY,
|
|
444
|
-
/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/,
|
|
445
|
-
/\bgh[pousr]_[A-Za-z0-9]{20,}/,
|
|
446
|
-
/\bgithub_pat_[A-Za-z0-9_]{20,}/,
|
|
447
|
-
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
|
|
448
|
-
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
449
|
-
/\bchp_[A-Za-z0-9_-]{20,}/,
|
|
450
|
-
# A one-time link code (LinkCodes::CODE_RE): a bearer credential
|
|
451
|
-
# until its grant expires (an owner's, 10 minutes).
|
|
452
|
-
/\bchc_[A-Za-z0-9_-]{43}/,
|
|
453
|
-
/\bAIza[0-9A-Za-z_-]{30,}/,
|
|
454
|
-
/\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/,
|
|
455
|
-
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
|
|
456
|
-
%r{https://hooks\.slack\.com/services/[A-Za-z0-9/_-]+}
|
|
457
|
-
].freeze
|
|
458
|
-
# The value after an Authorization (or a Bridge's secret,
|
|
459
|
-
# BridgeSecret::HEADER) header or a token=/key= pair.
|
|
460
|
-
VALUES = [
|
|
461
|
-
/(\b(?:Authorization|X-Chi-Bridge):\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/i,
|
|
462
|
-
/((?<![A-Za-z0-9])[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*[=:]\s*["']?)[^\s"'&`,;}]+/i,
|
|
463
|
-
# A URL's password: scheme://user:PASSWORD@host.
|
|
464
|
-
%r{(\b[a-z][a-z0-9+.-]*://[^/\s:@]+:)[^@\s/]+(?=@)}i
|
|
465
|
-
].freeze
|
|
466
|
-
# A text that ends where one of VALUES' values (or a private key's
|
|
467
|
-
# BEGIN line) would go on: a stream cut there would send the value
|
|
468
|
-
# on its own, unredacted (ParticipantStreamFilter never cuts there).
|
|
469
|
-
OPEN_VALUE = /(?:\b(?:Authorization|X-Chi-Bridge):\s*(?:(?:Bearer|Basic|Token)\s*)?|[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*(?:[=:]\s*["']?)?|-----BEGIN [A-Z0-9 ]*)\z/i
|
|
470
|
-
|
|
471
|
-
module_function
|
|
472
|
-
|
|
473
|
-
# +value+ with every string in it redacted (hash values, array items).
|
|
474
|
-
def deep(value, viewer)
|
|
475
|
-
case value
|
|
476
|
-
when String then text(value, viewer)
|
|
477
|
-
when Hash then value.transform_values { |item| deep(item, viewer) }
|
|
478
|
-
when Array then value.map { |item| deep(item, viewer) }
|
|
479
|
-
else value
|
|
480
|
-
end
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
def text(value, viewer)
|
|
484
|
-
out = value.dup
|
|
485
|
-
viewer.secrets.each { |secret| out = out.gsub(secret, MARK) }
|
|
486
|
-
PATTERNS.each { |pattern| out = out.gsub(pattern, MARK) }
|
|
487
|
-
VALUES.each { |pattern| out = out.gsub(pattern) { "#{Regexp.last_match(1)}#{MARK}" } }
|
|
488
|
-
out = within(out, viewer.cwd.to_s)
|
|
489
|
-
home = viewer.home.to_s
|
|
490
|
-
out = out.gsub(%r{#{Regexp.escape(home)}(?=/|\b|\z)}, "~") if home.length > 1
|
|
491
|
-
out
|
|
492
|
-
end
|
|
493
|
-
|
|
494
|
-
# +text+ with +dir+ (the session's folder) shown relative: "dir/x"
|
|
495
|
-
# as "x", "dir" alone as ".".
|
|
496
|
-
def within(text, dir)
|
|
497
|
-
return text if dir.length < 2
|
|
498
|
-
|
|
499
|
-
text.gsub(%r{#{Regexp.escape(dir)}(/|(?=\b|\z))}) { Regexp.last_match(1) == "/" ? "" : "." }
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
422
|
end
|
|
503
423
|
end
|
|
504
424
|
end
|