samagotchi 0.16.0 → 0.18.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +73 -19
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +12 -5
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -1
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# subcommands have their own flags).
|
|
11
11
|
class BundleCommand
|
|
12
12
|
USAGE = <<~TEXT
|
|
13
|
-
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build> [options]
|
|
13
|
+
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash> [options]
|
|
14
14
|
|
|
15
15
|
install <source> [--scope system|project] [--force]
|
|
16
16
|
Install a memory bundle from a directory, zip, tar archive, or git URL.
|
|
@@ -36,7 +36,11 @@ module Samagotchi
|
|
|
36
36
|
build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
|
|
37
37
|
Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
|
|
38
38
|
--scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
|
|
39
|
-
FILES... optional allowlist of *.md basenames to include (default: all).
|
|
39
|
+
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
|
|
40
|
+
|
|
41
|
+
trash [--empty] [--dry-run] [--older-than DAYS]
|
|
42
|
+
List bundle trash (moved files from uninstalls/upgrades).
|
|
43
|
+
--empty deletes all trash folders. --older-than DAYS keeps only recent ones.
|
|
40
44
|
TEXT
|
|
41
45
|
|
|
42
46
|
# Each subcommand's --help (and only --help: -h and help are taken as
|
|
@@ -57,13 +61,14 @@ module Samagotchi
|
|
|
57
61
|
build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
|
|
58
62
|
Build local memories and installed hooks into a shareable bundle (dir or zip).
|
|
59
63
|
--scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
|
|
60
|
-
FILES... optional allowlist of *.md basenames to include (default: all).
|
|
64
|
+
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
|
|
61
65
|
|
|
62
66
|
Examples:
|
|
63
67
|
chi bundle build --scope system
|
|
64
68
|
chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip
|
|
65
69
|
chi bundle build --scope system --out ./my-bundle/ identity.md work.md
|
|
66
70
|
TEXT
|
|
71
|
+
TRASH_HELP = "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
|
|
67
72
|
|
|
68
73
|
# The subcommands' flags: --help only; a word not starting with -- is
|
|
69
74
|
# a positional (install's source, -h included). --scope takes whatever
|
|
@@ -83,6 +88,11 @@ module Samagotchi
|
|
|
83
88
|
BUILD_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/, dash_values: false) do |f|
|
|
84
89
|
%w[--scope --name --version --description --out].each { |name| f.value name }
|
|
85
90
|
end
|
|
91
|
+
TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
92
|
+
f.switch "--empty"
|
|
93
|
+
f.switch "--dry-run"
|
|
94
|
+
f.value "--older-than"
|
|
95
|
+
end
|
|
86
96
|
|
|
87
97
|
# @param argv [Array<String>] the arguments after "bundle"
|
|
88
98
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
@@ -108,8 +118,9 @@ module Samagotchi
|
|
|
108
118
|
when "diff" then diff(@argv[1..])
|
|
109
119
|
when "list" then list
|
|
110
120
|
when "build" then build(@argv[1..])
|
|
121
|
+
when "trash" then trash(@argv[1..])
|
|
111
122
|
else
|
|
112
|
-
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
|
|
123
|
+
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash"
|
|
113
124
|
return CLI::Exit::USAGE
|
|
114
125
|
end
|
|
115
126
|
end
|
|
@@ -272,6 +283,7 @@ module Samagotchi
|
|
|
272
283
|
begin
|
|
273
284
|
uninstaller.run
|
|
274
285
|
@stdout.puts "Uninstalled bundle '#{bundle_name}'"
|
|
286
|
+
puts_trashed(uninstaller.trashed_files, uninstaller.trash_dir)
|
|
275
287
|
@stdout.puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
|
|
276
288
|
hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
|
|
277
289
|
@stdout.puts "Hooks removed: #{hook_removed}" if hook_removed > 0
|
|
@@ -311,9 +323,13 @@ module Samagotchi
|
|
|
311
323
|
if result.gone
|
|
312
324
|
@stdout.puts "Uninstalled bundle '#{name}'"
|
|
313
325
|
@stdout.puts "Removed: #{result.removed.join(", ")}" unless result.removed.empty?
|
|
326
|
+
result.trash.each_value { |files, dir| puts_trashed(files, dir) }
|
|
327
|
+
result.warnings.each { |w| @stdout.puts w }
|
|
314
328
|
return 0
|
|
315
329
|
end
|
|
316
330
|
@stdout.puts "Removed from #{name}: #{result.removed.join(", ")}" unless result.removed.empty?
|
|
331
|
+
result.trash.each_value { |files, dir| puts_trashed(files, dir) }
|
|
332
|
+
result.warnings.each { |w| @stdout.puts w }
|
|
317
333
|
result.blocked.each { |member, why| @stdout.puts "Kept #{member}: #{why}" }
|
|
318
334
|
@stderr.puts "Uninstall failed: #{name} stays installed until #{result.blocked.keys.join(", ")} goes " \
|
|
319
335
|
"(chi bundle uninstall #{name} --force)"
|
|
@@ -323,6 +339,11 @@ module Samagotchi
|
|
|
323
339
|
1
|
|
324
340
|
end
|
|
325
341
|
|
|
342
|
+
# Memory files an uninstall moved to the trash (MemoryBundle::Trash).
|
|
343
|
+
def puts_trashed(files, dir)
|
|
344
|
+
@stdout.puts "Moved to the trash: #{files.join(", ")} (#{dir})" if dir && !files.empty?
|
|
345
|
+
end
|
|
346
|
+
|
|
326
347
|
def status(rest)
|
|
327
348
|
name = rest.find { |a| !a.start_with?("--") }
|
|
328
349
|
require "json"
|
|
@@ -557,6 +578,7 @@ module Samagotchi
|
|
|
557
578
|
@stdout.puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
|
|
558
579
|
@stdout.puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
|
|
559
580
|
@stdout.puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
|
|
581
|
+
builder.warnings.each { |w| @stdout.puts w }
|
|
560
582
|
unless result[:placeholder_warnings].empty?
|
|
561
583
|
@stdout.puts "Placeholders:"
|
|
562
584
|
result[:placeholder_warnings].each { |w| @stdout.puts " #{w}" }
|
|
@@ -568,6 +590,101 @@ module Samagotchi
|
|
|
568
590
|
end
|
|
569
591
|
end
|
|
570
592
|
|
|
593
|
+
def trash(rest)
|
|
594
|
+
parsed = parse_flags(TRASH_FLAGS, rest, "trash", help: TRASH_HELP)
|
|
595
|
+
return parsed if parsed.is_a?(Integer)
|
|
596
|
+
|
|
597
|
+
opts = parsed.options
|
|
598
|
+
empty = opts.fetch(:empty, false)
|
|
599
|
+
dry_run = opts.fetch(:dry_run, false)
|
|
600
|
+
older_than = opts[:older_than]
|
|
601
|
+
|
|
602
|
+
# --older-than and --dry-run without --empty are a usage error
|
|
603
|
+
if (older_than || dry_run) && !empty
|
|
604
|
+
@stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
|
|
605
|
+
return CLI::Exit::USAGE
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# Validate older_than if given
|
|
609
|
+
if older_than
|
|
610
|
+
if older_than.to_s.match?(/\A\d+\z/) && older_than.to_i > 0
|
|
611
|
+
older_than = older_than.to_i
|
|
612
|
+
else
|
|
613
|
+
@stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
|
|
614
|
+
return CLI::Exit::USAGE
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
if empty
|
|
619
|
+
do_empty(older_than_days: older_than, dry_run: dry_run)
|
|
620
|
+
else
|
|
621
|
+
do_list
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def do_list
|
|
626
|
+
entries = Samagotchi::MemoryBundle::Trash.entries
|
|
627
|
+
if entries.empty?
|
|
628
|
+
@stdout.puts "The bundle trash is empty."
|
|
629
|
+
return 0
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
entries.each do |e|
|
|
633
|
+
@stdout.puts " #{e.name} #{e.files} file#{'s' unless e.files == 1} #{format_bytes(e.bytes)} #{format_age(e.time)}"
|
|
634
|
+
end
|
|
635
|
+
@stdout.puts "\nEmpty it with: chi bundle trash --empty [--older-than DAYS]"
|
|
636
|
+
0
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def do_empty(older_than_days:, dry_run:)
|
|
640
|
+
entries = Samagotchi::MemoryBundle::Trash.empty!(
|
|
641
|
+
older_than_days: older_than_days,
|
|
642
|
+
dry_run: dry_run,
|
|
643
|
+
)
|
|
644
|
+
if entries.empty?
|
|
645
|
+
@stdout.puts "The bundle trash is empty."
|
|
646
|
+
return 0
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
total_files = entries.sum(&:files)
|
|
650
|
+
if dry_run
|
|
651
|
+
entries.each do |e|
|
|
652
|
+
@stdout.puts "Would delete: #{e.name} (#{e.files} file#{'s' unless e.files == 1}, #{format_bytes(e.bytes)})"
|
|
653
|
+
end
|
|
654
|
+
@stdout.puts "\n(dry-run: #{entries.size} folder#{'s' unless entries.size == 1}, #{total_files} file#{'s' unless total_files == 1})"
|
|
655
|
+
else
|
|
656
|
+
@stdout.puts "Deleted #{entries.size} folder#{'s' unless entries.size == 1} (#{total_files} file#{'s' unless total_files == 1}) from the trash."
|
|
657
|
+
end
|
|
658
|
+
0
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Format bytes as human-readable (B, KB, MB with one decimal)
|
|
662
|
+
def format_bytes(bytes)
|
|
663
|
+
return "0 B" if bytes == 0
|
|
664
|
+
if bytes < 1024
|
|
665
|
+
"#{bytes} B"
|
|
666
|
+
elsif bytes < 1_048_576
|
|
667
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
668
|
+
else
|
|
669
|
+
"#{(bytes / 1_048_576.0).round(1)} MB"
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Format age as "just now", "N minutes ago", "N hours ago", "N days ago"
|
|
674
|
+
def format_age(time)
|
|
675
|
+
now = Time.now
|
|
676
|
+
diff = now - time
|
|
677
|
+
if diff < 60
|
|
678
|
+
"just now"
|
|
679
|
+
elsif diff < 3600
|
|
680
|
+
"#{(diff / 60).to_i} minutes ago"
|
|
681
|
+
elsif diff < 86_400
|
|
682
|
+
"#{(diff / 3600).to_i} hours ago"
|
|
683
|
+
else
|
|
684
|
+
"#{(diff / 86_400).to_i} days ago"
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
571
688
|
# +rest+ through +flags+, stopping at the first --help or error.
|
|
572
689
|
#
|
|
573
690
|
# @return [CLI::Flags::Result, Integer] the result, or the exit status
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
# earlier or `git -C` variants can get past them.
|
|
5
5
|
#
|
|
6
6
|
# Fields: id, tool (a name, a list, or shell = execute + task_create),
|
|
7
|
-
# command (Ruby regex), path (outside_repo or a glob),
|
|
7
|
+
# command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
|
|
8
|
+
# a shell call runs commit/add/reset/… in another checkout), verdict (ask|deny),
|
|
8
9
|
# reason, scopes (for an ask: once, session, repo, rule; default all).
|
|
9
10
|
# [^;&|\n]* keeps a match inside one command of a chain; the git rules match
|
|
10
11
|
# the subcommand after optional global options (git -C dir push).
|
|
@@ -62,12 +63,26 @@ rules:
|
|
|
62
63
|
verdict: ask
|
|
63
64
|
reason: runs decoded text as a script
|
|
64
65
|
|
|
65
|
-
# ──
|
|
66
|
+
# ── work outside this session's repo, and git hooks ───────────────────
|
|
67
|
+
# outside_repo: not in the session's repo (its folder outside a repo);
|
|
68
|
+
# tmp dirs and memory files don't count.
|
|
66
69
|
- id: write-outside-repo
|
|
67
70
|
tool: [write, edit]
|
|
68
71
|
path: outside_repo
|
|
69
72
|
verdict: ask
|
|
70
|
-
reason: writes outside
|
|
73
|
+
reason: writes outside this session's repo
|
|
74
|
+
# cd/git -C into another checkout, then commit, add, reset, checkout,
|
|
75
|
+
# switch, rebase, merge, push, stash, rm, mv, cherry-pick, revert, pull,
|
|
76
|
+
# restore or am. Read-only git (status, log, diff) and tests there run.
|
|
77
|
+
# "This call in this repo" would never match again (the command holds
|
|
78
|
+
# the commit message), so it isn't offered: "rule in this repo" is the
|
|
79
|
+
# always-for-here answer.
|
|
80
|
+
- id: git-outside-repo
|
|
81
|
+
tool: shell
|
|
82
|
+
git: outside_repo
|
|
83
|
+
verdict: ask
|
|
84
|
+
scopes: [once, session, rule]
|
|
85
|
+
reason: runs git that changes another checkout, outside this session's repo
|
|
71
86
|
- id: git-hooks-write
|
|
72
87
|
tool: [write, edit]
|
|
73
88
|
path: '**/.git/hooks/**'
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
5
|
+
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
# models: in small-models.yml (guardrails.small_models)
|
|
8
|
-
requires_chi: ">= 0.
|
|
7
|
+
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
|
|
8
|
+
requires_chi: ">= 0.18.0"
|
|
9
9
|
files:
|
|
10
10
|
guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
|
|
@@ -60,7 +60,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
60
60
|
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
|
|
61
61
|
- `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
62
62
|
- `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
|
|
63
|
-
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
63
|
+
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
64
64
|
|
|
65
65
|
```yaml
|
|
66
66
|
guardrails:
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.18.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
10
|
-
memory_guide.md: sha256:
|
|
8
|
+
self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
|
|
9
|
+
config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
|
|
10
|
+
memory_guide.md: sha256:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
|
@@ -16,7 +16,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
|
|
|
16
16
|
| `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
|
|
17
17
|
|
|
18
18
|
- The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the resolved root and folder. Outside a repository the root is the working directory. A separate clone is a different project.
|
|
19
|
-
- `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description
|
|
19
|
+
- `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
|
|
20
20
|
|
|
21
21
|
## Tools you have
|
|
22
22
|
|
|
@@ -85,13 +85,13 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
85
85
|
|
|
86
86
|
| Command | Purpose |
|
|
87
87
|
|---------|---------|
|
|
88
|
-
| `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips. Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
|
|
88
|
+
| `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
|
|
89
89
|
| `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
|
|
90
|
-
| `uninstall <bundle> [--force]` |
|
|
90
|
+
| `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
|
|
91
91
|
| `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
|
|
92
92
|
| `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
|
|
93
93
|
| `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
|
|
94
|
-
| `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
|
|
94
|
+
| `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
|
|
95
95
|
|
|
96
96
|
**Scope resolution for install/build:**
|
|
97
97
|
- CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`Installer#resolve_target_dir`).
|
|
@@ -119,7 +119,7 @@ chi bundle build --scope system --out updated.zip
|
|
|
119
119
|
### Provenance internals (for debugging)
|
|
120
120
|
|
|
121
121
|
- Each installed bundle is recorded at `<system dir>/.bundles/<name>/manifest.json` + `bases/<file>.md` snapshots (`Provenance`). Used only for upgrade `Merger` and `status`/`diff`. Build does **not** write provenance.
|
|
122
|
-
- `index.md` is
|
|
122
|
+
- A bundle records only the files it wrote. `index.md` updates aren't fatal: a failed one is a warning in the install/uninstall output.
|
|
123
123
|
|
|
124
124
|
## Best practices for the agent
|
|
125
125
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
## Start here
|
|
4
4
|
- `chi self` (via `execute`) prints my version, **source dir**, config path, hooks dir,
|
|
5
5
|
memory dirs, sessions dir, model/host and bundles. Use its source dir; don't hunt via `which`/`gem list`/`find /`.
|
|
6
|
+
Its `model` row is this session's model (live after `/model`), `model key` my overlay key.
|
|
6
7
|
- My current session id is in the system prompt; resume with `chi --resume <id>`.
|
|
7
8
|
- My debug log is the `log` line of `chi self` (default `$XDG_STATE_HOME/samagotchi/samagotchi.log`,
|
|
8
9
|
next to the sessions dir; not `~/.local/state` when `XDG_STATE_HOME` is set). One record per line,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Commands
|
|
5
7
|
# The slash (and bang) commands a session knows, in lookup order: the
|
|
@@ -74,6 +76,32 @@ module Samagotchi
|
|
|
74
76
|
|
|
75
77
|
def command?(line) = !lookup(line).nil?
|
|
76
78
|
|
|
79
|
+
# A line that is one word: a slash and a name, no spaces, no second
|
|
80
|
+
# slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
|
|
81
|
+
# are prompts).
|
|
82
|
+
UNKNOWN_COMMAND_WORD = /\A\/[A-Za-z][A-Za-z0-9_-]*\z/
|
|
83
|
+
|
|
84
|
+
# Whether +line+ is a command word no command answers: a typo like
|
|
85
|
+
# `/modle`, which a UI must not send to the model as a prompt.
|
|
86
|
+
def unknown_command_word?(line)
|
|
87
|
+
text = line.to_s.strip
|
|
88
|
+
return false unless UNKNOWN_COMMAND_WORD.match?(text)
|
|
89
|
+
|
|
90
|
+
lookup(text).nil? && lookup_local(text).nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What a UI shows for an unknown command word, or nil when +line+ is
|
|
94
|
+
# not one: `Unknown command /modle. Did you mean /model? /help lists
|
|
95
|
+
# the commands.` The "Did you mean" part needs a close name among the
|
|
96
|
+
# entries (DidYouMean::SpellChecker).
|
|
97
|
+
def unknown_command_hint(line)
|
|
98
|
+
return nil unless unknown_command_word?(line)
|
|
99
|
+
|
|
100
|
+
text = line.to_s.strip
|
|
101
|
+
close = DidYouMean::SpellChecker.new(dictionary: @entries.map(&:name)).correct(text).first
|
|
102
|
+
"Unknown command #{text}. #{close ? "Did you mean #{close}? " : ""}/help lists the commands."
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
# @return [Entry, nil] the first entry the UI runs itself (local) that
|
|
78
106
|
# matches +line+, whatever its uis (a UI answers the others' too:
|
|
79
107
|
# the REPL's /detach note)
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -142,6 +142,12 @@ module Samagotchi
|
|
|
142
142
|
# Asks again in the same turn after an empty answer (EmptyAnswerRetry);
|
|
143
143
|
# 0 = off, capped at 3. No CLI flag: workers get no CLI args.
|
|
144
144
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
145
|
+
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
146
|
+
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
147
|
+
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
|
|
148
|
+
# Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
|
|
149
|
+
# config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
|
|
150
|
+
Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
|
|
145
151
|
# What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
|
|
146
152
|
Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
|
|
147
153
|
Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
|
|
@@ -161,7 +167,7 @@ module Samagotchi
|
|
|
161
167
|
Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
162
168
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
163
169
|
# Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
|
|
164
|
-
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default:
|
|
170
|
+
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
165
171
|
|
|
166
172
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
167
173
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session worker's continue offer: what it announces when a turn runs out
|
|
7
|
+
# of iterations, when a prompt or a reminder drops the offer, when a
|
|
8
|
+
# /continue answers it, and the continue turn a yes runs. TurnFlow keeps
|
|
9
|
+
# the offer itself (its checkpoint and context); this is the worker's side
|
|
10
|
+
# of it, which every UI hears about.
|
|
11
|
+
#
|
|
12
|
+
# The offer is also a question (kind "continue", the step-limit
|
|
13
|
+
# question), standing on the Engine's question desk between turns, so
|
|
14
|
+
# everything built on a pending question sees it: chi send --wait (exit
|
|
15
|
+
# 3), chi answer, the lists' waiting, the web's badge and bell. Its
|
|
16
|
+
# answer becomes the matching /continue line on the worker's command
|
|
17
|
+
# queue, which runs it as a typed one (#run_command's path).
|
|
18
|
+
class ContinueOffer
|
|
19
|
+
KIND = "continue"
|
|
20
|
+
HEADER = "Step limit"
|
|
21
|
+
CONTINUE = "Continue"
|
|
22
|
+
STOP = "Stop"
|
|
23
|
+
PROMPT_PREVIEW = 200
|
|
24
|
+
# last_turn's outcome after a Stop: no turn ran, but the wait of
|
|
25
|
+
# whoever answered (chi answer) ends on it.
|
|
26
|
+
NOT_CONTINUED = "not_continued"
|
|
27
|
+
|
|
28
|
+
# @param engine [Engine] announces, records activity, holds the question
|
|
29
|
+
# @param turn_flow [TurnFlow] owns the offer
|
|
30
|
+
# @param run_turn [#call] (prompt, **turn_args, &block) the worker's
|
|
31
|
+
# run_engine_turn
|
|
32
|
+
# @param max_iterations [#call] (no_interrupt) → Integer
|
|
33
|
+
# @param queue_command [#call] (line, client_id:) queues a session
|
|
34
|
+
# command as a card's (Bridge#queue_command, card: true)
|
|
35
|
+
def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil)
|
|
36
|
+
@engine = engine
|
|
37
|
+
@turn_flow = turn_flow
|
|
38
|
+
@run_turn = run_turn
|
|
39
|
+
@max_iterations = max_iterations
|
|
40
|
+
@queue_command = queue_command
|
|
41
|
+
# A question was posted since the last close (it may have been
|
|
42
|
+
# answered or superseded meanwhile).
|
|
43
|
+
@asked = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_writer :queue_command
|
|
47
|
+
|
|
48
|
+
def awaiting? = @turn_flow.awaiting_continue?
|
|
49
|
+
|
|
50
|
+
# TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
|
|
51
|
+
# the continue offer of a turn that ran out of iterations; an offer made
|
|
52
|
+
# (or kept, after a cancelled continue turn) is announced and asked.
|
|
53
|
+
def after_turn(result, continue: false, no_interrupt: false)
|
|
54
|
+
outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
|
|
55
|
+
return unless outcome == :continue_offered || outcome == :continue_cancelled
|
|
56
|
+
|
|
57
|
+
open
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A prompt (or a reminder turn) taken while a continue is offered
|
|
61
|
+
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
62
|
+
def drop(origin)
|
|
63
|
+
return unless awaiting?
|
|
64
|
+
|
|
65
|
+
close("dropped")
|
|
66
|
+
@engine.synchronize_events do
|
|
67
|
+
@turn_flow.drop_offer!
|
|
68
|
+
@engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
|
|
69
|
+
end
|
|
70
|
+
@engine.record_activity
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether a command's +result+ answered the offer that was pending
|
|
74
|
+
# (+awaiting+) as it started.
|
|
75
|
+
def resolved?(awaiting, result)
|
|
76
|
+
awaiting && result.decision && result.decision != :invalid ? true : false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Called with the event log held, before the command's command_ran. A
|
|
80
|
+
# Stop runs no turn: last_turn says so (not_continued), so a wait on
|
|
81
|
+
# it ends (chi answer --option Stop).
|
|
82
|
+
def announce_resolved(result, command)
|
|
83
|
+
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
|
|
84
|
+
return if result.resume
|
|
85
|
+
|
|
86
|
+
session = @engine.session
|
|
87
|
+
session.last_turn = { "outcome" => NOT_CONTINUED, "ended_at" => Time.now.iso8601(3) } if session
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# After a command's command_ran and save: a /continue that answered the
|
|
91
|
+
# offer closes its question; a command that dropped the offer
|
|
92
|
+
# otherwise (!rollback) too.
|
|
93
|
+
# @param resolved [Boolean] it answered the offer (#resolved?)
|
|
94
|
+
def after_command(result, resolved:)
|
|
95
|
+
close(resolved ? "answered" : "dropped") unless awaiting?
|
|
96
|
+
return unless resolved
|
|
97
|
+
|
|
98
|
+
# An offer answered without a turn ("no") is activity: the recap
|
|
99
|
+
# written at the offer (it says the turn stopped) gets rewritten.
|
|
100
|
+
@engine.record_activity unless result.resume
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The continue offer was answered yes: resume the conversation without a
|
|
104
|
+
# user message, with the iteration limit the offer's turn had. A failure
|
|
105
|
+
# keeps the offer, as the REPL does ("continue prompt preserved"), and
|
|
106
|
+
# asks again.
|
|
107
|
+
def run_continue_turn(command)
|
|
108
|
+
offer = @turn_flow.offer
|
|
109
|
+
close("answered")
|
|
110
|
+
@turn_flow.before_continue_turn
|
|
111
|
+
@run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
|
|
112
|
+
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
113
|
+
if error
|
|
114
|
+
open(offer)
|
|
115
|
+
else
|
|
116
|
+
after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The question goes (the offer went, or was answered).
|
|
122
|
+
# @param reason [String] dropped, answered
|
|
123
|
+
def close(reason)
|
|
124
|
+
return unless @asked
|
|
125
|
+
|
|
126
|
+
@asked = false
|
|
127
|
+
@engine.withdraw_question(reason)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def open(offer = @turn_flow.offer)
|
|
133
|
+
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
134
|
+
ask
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Post the step-limit question while the offer stands (again, after a
|
|
138
|
+
# question that superseded it closed).
|
|
139
|
+
def ask
|
|
140
|
+
return unless awaiting?
|
|
141
|
+
|
|
142
|
+
@asked = true
|
|
143
|
+
@engine.post_question(question_fields(@turn_flow.offer), on_answer: method(:answered),
|
|
144
|
+
on_superseded_close: method(:ask))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def question_fields(offer)
|
|
148
|
+
limit = limit_of_last_turn
|
|
149
|
+
context = offer[:context] || {}
|
|
150
|
+
text = "The turn ran out of iterations#{" (#{limit} steps)" if limit} before it answered. Continue it?"
|
|
151
|
+
prompt = context[:original_prompt].to_s.strip
|
|
152
|
+
unless prompt.empty?
|
|
153
|
+
prompt = "#{prompt[0, PROMPT_PREVIEW].rstrip}..." if prompt.length > PROMPT_PREVIEW
|
|
154
|
+
text += "\nPrompt: #{prompt}"
|
|
155
|
+
end
|
|
156
|
+
{ kind: KIND, header: HEADER, question: text, options: [CONTINUE, STOP], multi_select: false,
|
|
157
|
+
allow_freeform: true, limit: limit, context: context }.compact
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The limit the offer's turn ran out at (Engine's last_turn marker).
|
|
161
|
+
def limit_of_last_turn
|
|
162
|
+
last = @engine.session&.last_turn
|
|
163
|
+
last.is_a?(Hash) && last["exhausted"] ? last["limit"] : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The question's answer as the /continue line a typed answer would be:
|
|
167
|
+
# Continue → yes; Stop → no; a text (with Stop, or alone) → no, <text>.
|
|
168
|
+
def answered(answer, client_id:)
|
|
169
|
+
text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
|
|
170
|
+
line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
|
|
171
|
+
elsif text.empty? then "/continue no"
|
|
172
|
+
else "/continue no, #{text}"
|
|
173
|
+
end
|
|
174
|
+
@queue_command&.call(line, client_id: client_id)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|