samagotchi 0.62.0 → 0.64.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 +78 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +12 -2
- data/docs/cli.md +20 -9
- data/docs/configuration.md +16 -9
- data/docs/context.md +13 -3
- data/docs/hooks.md +6 -3
- data/docs/internals/engine.md +10 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +96 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +11 -8
- data/docs/sub-agent.md +27 -4
- data/docs/testing.md +13 -4
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +41 -5
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +6 -3
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_config.rb +6 -6
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/row_refs.rb +3 -4
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
- data/lib/samagotchi/terminal_ui.rb +103 -68
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +33 -8
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -4
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/app.js +53 -91
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +6 -4
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/question_cards.js +3 -2
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/timing.js +2 -8
- data/lib/samagotchi/web/public/turn_html.js +119 -59
- data/lib/samagotchi/web/public/turn_model.js +13 -3
- data/lib/samagotchi/web/public/turn_view.js +73 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +53 -35
- data/lib/samagotchi/worker_inbound.rb +12 -11
- metadata +9 -1
|
@@ -297,7 +297,7 @@ class Plugin
|
|
|
297
297
|
def register(chi)
|
|
298
298
|
chi.on(:before_turn) { |_event| reset }
|
|
299
299
|
chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
|
|
300
|
-
chi.on(:after_tool_call) { |event| after(event) }
|
|
300
|
+
chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
|
|
301
301
|
return unless @thinking["watch"]
|
|
302
302
|
|
|
303
303
|
chi.on(:before_generation) do |_event|
|
|
@@ -377,6 +377,7 @@ class Plugin
|
|
|
377
377
|
@warned = {} # key => true once notified this turn
|
|
378
378
|
@denials = 0
|
|
379
379
|
@pending = nil
|
|
380
|
+
@stop_card = nil # the stop's card, posted after the denied call
|
|
380
381
|
@thinking_loops = 0
|
|
381
382
|
@good_steps = 0
|
|
382
383
|
@watch = nil
|
|
@@ -412,7 +413,8 @@ class Plugin
|
|
|
412
413
|
# A call's result, paired with the before_tool_call that keyed it. A call
|
|
413
414
|
# that was denied (by us or another voter) ran nothing: no result, or the
|
|
414
415
|
# deny text would become the key's result and the next call would pass.
|
|
415
|
-
def after(event)
|
|
416
|
+
def after(event, ctx)
|
|
417
|
+
post_stop_card(ctx)
|
|
416
418
|
pending = @pending
|
|
417
419
|
@pending = nil
|
|
418
420
|
return unless pending && event[:tool].to_s == pending[:key].first
|
|
@@ -426,16 +428,25 @@ class Plugin
|
|
|
426
428
|
@previews[key] = preview(output)
|
|
427
429
|
end
|
|
428
430
|
|
|
431
|
+
# The turn stops now; its card waits for the denied call's
|
|
432
|
+
# after_tool_call (#post_stop_card), so a UI that prints events in order
|
|
433
|
+
# shows it under that call's row, not before it.
|
|
429
434
|
def stop(ctx)
|
|
430
435
|
ctx.stop_turn("the model kept repeating the same calls")
|
|
431
436
|
repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
|
|
432
437
|
lines = repeated.map do |key, n|
|
|
433
438
|
"- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
|
|
434
439
|
end
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
440
|
+
@stop_card = { title: "loop-guard stopped the turn",
|
|
441
|
+
body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
|
|
442
|
+
"Tell it what to try instead, or where to look.",
|
|
443
|
+
level: :warn }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def post_stop_card(ctx)
|
|
447
|
+
card = @stop_card
|
|
448
|
+
@stop_card = nil
|
|
449
|
+
ctx.card(**card) if card
|
|
439
450
|
end
|
|
440
451
|
|
|
441
452
|
def warn(ctx, key, what)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slow-commands
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:6bf5da2501a6404cda01c95975b005fce179f756c56222771c5ee857d7ba4b98
|
|
10
10
|
# Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
|
|
11
11
|
# call:, elapsed_ms: and result: (0.57.0)
|
|
12
12
|
requires_chi: ">= 0.59.0"
|
|
@@ -75,9 +75,11 @@ class Plugin
|
|
|
75
75
|
TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
|
|
76
76
|
# Still running at the limit, handed over as a task (chi >= 0.59.0).
|
|
77
77
|
HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
|
|
78
|
-
# A stopped command's first words: the user's Stop,
|
|
79
|
-
# (chi >= 0.62.0; an older chi has no
|
|
80
|
-
|
|
78
|
+
# A stopped command's first words: the user's Stop, a signal to chi
|
|
79
|
+
# (chi >= 0.62.0) or a session stop (chi >= 0.64.0); an older chi has no
|
|
80
|
+
# such text.
|
|
81
|
+
STOPPED = %i[STOPPED_BY_USER NOT_RUN_ON_STOP STOPPED_BY_SIGNAL NOT_RUN_ON_SIGNAL
|
|
82
|
+
STOPPED_BY_SESSION_STOP NOT_RUN_ON_SESSION_STOP].filter_map do |name|
|
|
81
83
|
Samagotchi::Tools::Execute.const_get(name) if Samagotchi::Tools::Execute.const_defined?(name)
|
|
82
84
|
end.freeze
|
|
83
85
|
LEX = Samagotchi::Guardrails::ShellLex
|
|
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
57
57
|
**Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
|
|
58
58
|
|
|
59
59
|
- `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
|
|
60
|
-
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`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; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`;
|
|
60
|
+
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`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; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (`SAMAGOTCHI_HOSTS_JSON` replaces it for a process it is set on; `SessionManager.spawn_options` unsets it for workers).
|
|
61
61
|
- `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`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: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --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". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `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
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.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.64.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
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
10
|
-
memory_guide.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:99ad8a596cc0f5129caf82cc00314d597f0ec309be96e2d48ca4fa936b4ae343
|
|
10
|
+
memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
94
94
|
- Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
|
|
95
95
|
- Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
|
|
96
96
|
- A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
|
|
97
|
-
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
97
|
+
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
98
98
|
|
|
99
99
|
### CLI — `chi bundle`
|
|
100
100
|
|
|
@@ -231,11 +231,16 @@ module Samagotchi
|
|
|
231
231
|
return nil unless child.parent_id == @session_id
|
|
232
232
|
|
|
233
233
|
before = @taken[child_id]&.after || Tools::DelegateCursors.get(@session_id, child_id, state_dir: @state_dir)
|
|
234
|
+
# A follow-up's turn (DelegateCursor#turn) is reported by its own
|
|
235
|
+
# reply, as delegate_result takes it, not a newer queued turn's.
|
|
234
236
|
wait = ReplyWait.call(child_id, state_dir: @state_dir, cursor: before.reply_file, baseline: before.baseline,
|
|
235
|
-
timeout: 0)
|
|
237
|
+
timeout: 0, turn: before.turn)
|
|
236
238
|
return nil unless reportable?(wait)
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
# A reported end is the follow-up turn's too (DelegateCursor#turn):
|
|
241
|
+
# a later wait on it would repeat its reply.
|
|
242
|
+
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
243
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
239
244
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
240
245
|
text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
|
|
241
246
|
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
|
|
@@ -57,10 +57,17 @@ module Samagotchi
|
|
|
57
57
|
# report it. Ctrl-C leaves the turn running (130).
|
|
58
58
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
59
59
|
# it counts as gone; nil waits for whatever wakes one
|
|
60
|
+
# @param turn [String, nil] the enqueued_id of the turn waited for
|
|
61
|
+
# (ReplyWait's turn:): a queued message's, or the turn an answer or
|
|
62
|
+
# a --wait with no message is about (ReplyWait.own_turn)
|
|
63
|
+
# @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
|
|
64
|
+
# queued:)
|
|
60
65
|
# @return [Integer] the exit status
|
|
61
|
-
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER
|
|
66
|
+
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
|
|
67
|
+
queued: false)
|
|
62
68
|
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
63
|
-
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL
|
|
69
|
+
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
|
|
70
|
+
queued: queued)
|
|
64
71
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
65
72
|
@reported = true
|
|
66
73
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "delivery"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Where a line typed at a terminal's prompt goes, for both terminals (the
|
|
7
|
+
# REPL's TerminalUI#steer_line / #run_input_line, the attached
|
|
8
|
+
# AttachedLoop#submit). It only classifies; each UI keeps what it does
|
|
9
|
+
# with the answer (the REPL's mid-turn command policy and image lines, the
|
|
10
|
+
# attached TUI's continue offer and question). The web's composer.js
|
|
11
|
+
# routeComposerLine decides the same; spec/fixtures/composer_routes.json
|
|
12
|
+
# holds the cases both answer (docs/testing.md).
|
|
13
|
+
#
|
|
14
|
+
# In order:
|
|
15
|
+
# 1. /cut TEXT, /queue TEXT (.steer_choice), before every command check:
|
|
16
|
+
# the bare word is :usage. While a turn runs TEXT is a :deliver with
|
|
17
|
+
# its delivery (Delivery::CUT, Delivery::QUEUE); whether the worker
|
|
18
|
+
# can queue is the UI's to check. With none running TEXT is routed
|
|
19
|
+
# on as a line (step 2), its own /cut not read again.
|
|
20
|
+
# 2. A line: :empty; a terminal's own word (:local, the registry's
|
|
21
|
+
# lookup_local id: :exit, :archive, :detach, :stats, :recap…); a
|
|
22
|
+
# session command (:command); a typo of one (:typo, the hint in
|
|
23
|
+
# +reply+); else :prompt. A line with images (+images+) is a message:
|
|
24
|
+
# no command or typo check.
|
|
25
|
+
module ComposerRoute
|
|
26
|
+
CUT_COMMAND = "/cut"
|
|
27
|
+
QUEUE_COMMAND = "/queue"
|
|
28
|
+
# The word, then any whitespace and the text (as the registry's
|
|
29
|
+
# entries match them, so a line Tab or /help knows is never a prompt).
|
|
30
|
+
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
31
|
+
|
|
32
|
+
KINDS = %i[empty usage deliver local command typo prompt].freeze
|
|
33
|
+
|
|
34
|
+
# @!attribute kind [Symbol] one of KINDS
|
|
35
|
+
# @!attribute text [String, nil] the line stripped: a /cut's or
|
|
36
|
+
# /queue's own text, not the word
|
|
37
|
+
# @!attribute delivery [String, nil] a :deliver's Delivery value
|
|
38
|
+
# @!attribute reply [String, nil] a :typo's hint
|
|
39
|
+
# @!attribute local [Symbol, nil] a :local's registry id
|
|
40
|
+
# @!attribute choice [Symbol, nil] :cut or :queue when the line was
|
|
41
|
+
# typed with that word (an idle one's text routed on keeps it)
|
|
42
|
+
Route = Data.define(:kind, :text, :delivery, :reply, :local, :choice) do
|
|
43
|
+
def initialize(kind:, text: nil, delivery: nil, reply: nil, local: nil, choice: nil)
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param line [String, nil] as typed
|
|
49
|
+
# @param running [Boolean] a turn runs
|
|
50
|
+
# @param registry [Commands::Registry] the session's commands
|
|
51
|
+
# @param images [Integer] the attachments not in the text (the web's
|
|
52
|
+
# chips); a terminal's @path images are its own to handle (0)
|
|
53
|
+
# @return [Route]
|
|
54
|
+
def self.call(line, running:, registry:, images: 0)
|
|
55
|
+
text = line.to_s.strip
|
|
56
|
+
choice, message = steer_choice(text)
|
|
57
|
+
return line_route(text, registry: registry, images: images) unless choice
|
|
58
|
+
return Route.new(kind: :usage, choice: choice) if message.empty?
|
|
59
|
+
return line_route(message, registry: registry, images: images).with(choice: choice) unless running
|
|
60
|
+
|
|
61
|
+
Route.new(kind: :deliver, text: message, delivery: choice == :cut ? Delivery::CUT : Delivery::QUEUE, choice: choice)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The choice a line carries, as [:cut, "TEXT"] or [:queue, "TEXT"]; nil
|
|
65
|
+
# for any other line. The text is "" when the line named the choice
|
|
66
|
+
# without one.
|
|
67
|
+
# @param line [String, nil]
|
|
68
|
+
# @return [Array(Symbol, String), nil]
|
|
69
|
+
def self.steer_choice(line)
|
|
70
|
+
match = STEER_CHOICE.match(line.to_s.strip) or return nil
|
|
71
|
+
|
|
72
|
+
[match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.line_route(text, registry:, images:)
|
|
76
|
+
return Route.new(kind: :empty, text: text) if text.empty?
|
|
77
|
+
|
|
78
|
+
if images.zero?
|
|
79
|
+
local = registry.lookup_local(text)&.id
|
|
80
|
+
return Route.new(kind: :local, text: text, local: local) if local
|
|
81
|
+
return Route.new(kind: :command, text: text) if registry.command?(text)
|
|
82
|
+
|
|
83
|
+
hint = registry.unknown_command_hint(text)
|
|
84
|
+
return Route.new(kind: :typo, text: text, reply: hint) if hint
|
|
85
|
+
end
|
|
86
|
+
Route.new(kind: :prompt, text: text)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :line_route
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -857,7 +857,7 @@ module Samagotchi
|
|
|
857
857
|
HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
|
|
858
858
|
ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
859
859
|
|
|
860
|
-
# config.yml's hosts: (or a
|
|
860
|
+
# config.yml's hosts: (or a SAMAGOTCHI_HOSTS_JSON override) by lowercased
|
|
861
861
|
# name; an entry that is disabled or invalid (warned) is left out. No
|
|
862
862
|
# entry left: "default", from server.host/port/transport.
|
|
863
863
|
# @return [Hash{String => HostConfig}]
|
|
@@ -914,7 +914,7 @@ module Samagotchi
|
|
|
914
914
|
|
|
915
915
|
# The names (lowercased) of the hosts entries with enabled: false, which
|
|
916
916
|
# hosts_config leaves out. Read from the same source: config.yml's
|
|
917
|
-
# hosts:, or SAMAGOTCHI_HOSTS_JSON
|
|
917
|
+
# hosts:, or a SAMAGOTCHI_HOSTS_JSON override.
|
|
918
918
|
# @return [Array<String>]
|
|
919
919
|
def disabled_host_names(env: ENV, path: global_path(env: env))
|
|
920
920
|
raw = hosts_source(read_yaml(env: env, path: path), env)
|
|
@@ -926,7 +926,8 @@ module Samagotchi
|
|
|
926
926
|
end
|
|
927
927
|
|
|
928
928
|
# config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
|
|
929
|
-
#
|
|
929
|
+
# set on this process (specs; SessionManager.spawn_options unsets it for
|
|
930
|
+
# a worker, which reads the file).
|
|
930
931
|
def hosts_source(data, env)
|
|
931
932
|
raw = data[HOSTS_KEY] if data.is_a?(Hash)
|
|
932
933
|
env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
|
|
@@ -1041,6 +1042,8 @@ module Samagotchi
|
|
|
1041
1042
|
hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
|
|
1042
1043
|
end
|
|
1043
1044
|
|
|
1045
|
+
# hosts_config as a SAMAGOTCHI_HOSTS_JSON override (workers no longer
|
|
1046
|
+
# get one: they read config.yml).
|
|
1044
1047
|
def hosts_json_for_env(env: ENV, path: global_path(env: env))
|
|
1045
1048
|
hosts = hosts_config(env: env, path: path)
|
|
1046
1049
|
# Only serialize if non-default or explicitly configured hosts:
|
|
@@ -16,8 +16,9 @@ module Samagotchi
|
|
|
16
16
|
class ContextAbsorber
|
|
17
17
|
# +note+: Engine#add_context_note's hash; +subscription+: the session's
|
|
18
18
|
# Subscription after it (nil: dropped); +wake_note+: the same note with
|
|
19
|
-
# the wake line, for an update whose source asked to wake
|
|
20
|
-
# may start a turn with
|
|
19
|
+
# the wake line, for an update whose source asked to wake or a session
|
|
20
|
+
# push source's first text that asked (the worker may start a turn with
|
|
21
|
+
# it), else nil.
|
|
21
22
|
Delivery = Data.define(:name, :note, :subscription, :wake_note) do
|
|
22
23
|
def initialize(name:, note:, subscription:, wake_note: nil) = super
|
|
23
24
|
end
|
|
@@ -93,9 +94,12 @@ module Samagotchi
|
|
|
93
94
|
"(third-party text, not your user's words):\n> #{quoted}\n#{last_line}"
|
|
94
95
|
end
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
# The attached note's text; a wake turn's (+wake+) ends with the wake
|
|
98
|
+
# line in place of the background line.
|
|
99
|
+
def self.attached_text(name:, hint:, why:, summary:, wake: false)
|
|
97
100
|
why_part = why ? " Why: #{why}#{"." unless why.match?(/[.!?]\z/)}" : ""
|
|
98
|
-
|
|
101
|
+
last_line = wake ? WAKE_LAST_LINE : "#{BACKGROUND_LINE} #{read_line(name)}"
|
|
102
|
+
"Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{last_line}"
|
|
99
103
|
end
|
|
100
104
|
|
|
101
105
|
def self.read_line(name) = "Read it with context_read(name: \"#{name}\") when your user's request is about it."
|
|
@@ -105,6 +109,8 @@ module Samagotchi
|
|
|
105
109
|
# A source the session sees: attached when it has text and was never
|
|
106
110
|
# noted; updated for a new revision; an error once per run of failures
|
|
107
111
|
# after a success. A source with no text yet waits (nothing to read).
|
|
112
|
+
# An update that asked to wake carries a wake note, and so does a first
|
|
113
|
+
# text that asked when #first_text_wakes?.
|
|
108
114
|
def delivery_for(attached, sub, now)
|
|
109
115
|
snapshot = attached.snapshot
|
|
110
116
|
return nil unless snapshot.text?
|
|
@@ -112,8 +118,11 @@ module Samagotchi
|
|
|
112
118
|
name = attached.name
|
|
113
119
|
hint = snapshot.hint || attached.source.hint
|
|
114
120
|
if sub.seen.nil?
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
args = { name: name, hint: hint, why: attached.source.why, summary: snapshot.summary }
|
|
122
|
+
found = delivery(name, text_note_id(name, snapshot), self.class.attached_text(**args), now, seen(sub, snapshot))
|
|
123
|
+
return found unless first_text_wakes?(attached, sub)
|
|
124
|
+
|
|
125
|
+
return found.with(wake_note: found.note.merge(text: self.class.attached_text(**args, wake: true)))
|
|
117
126
|
end
|
|
118
127
|
if sub.seen != snapshot.revision
|
|
119
128
|
args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
|
|
@@ -131,6 +140,16 @@ module Samagotchi
|
|
|
131
140
|
sub.with(error_seen: snapshot.error_since))
|
|
132
141
|
end
|
|
133
142
|
|
|
143
|
+
# A first text may wake only for a session's pushed source (`chi context
|
|
144
|
+
# add NAME --push`, attached empty) that asked. Not for a command source
|
|
145
|
+
# (a URL/provider source has a cmd too): its first fetch runs as it is
|
|
146
|
+
# attached, while the user is likely looking at the session. Not for a
|
|
147
|
+
# project source: its first text may predate this session. A blank
|
|
148
|
+
# Subscription has seen_serial 0, so any revision that asked counts.
|
|
149
|
+
def first_text_wakes?(attached, sub)
|
|
150
|
+
attached.source.push? && attached.location.session? && attached.snapshot.wake_serial.to_i > sub.seen_serial
|
|
151
|
+
end
|
|
152
|
+
|
|
134
153
|
# A subscription whose source is gone: detached when the session was
|
|
135
154
|
# told of it, dropped quietly otherwise.
|
|
136
155
|
def detached(sub, now)
|
|
@@ -85,12 +85,17 @@ module Samagotchi
|
|
|
85
85
|
# Called with the event log held, before the command's command_ran. A
|
|
86
86
|
# Stop runs no turn: last_turn says so (not_continued), so a wait on
|
|
87
87
|
# it ends (chi answer --option Stop).
|
|
88
|
+
# It carries the offer turn's enqueued_id, so a wait on that turn
|
|
89
|
+
# (ReplyWait's turn:) ends on it too.
|
|
88
90
|
def announce_resolved(result, command)
|
|
89
91
|
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
|
|
90
92
|
return if result.resume
|
|
91
93
|
|
|
92
94
|
session = @engine.session
|
|
93
|
-
|
|
95
|
+
return unless session
|
|
96
|
+
|
|
97
|
+
session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3),
|
|
98
|
+
enqueued_id: offer_turn_id)
|
|
94
99
|
end
|
|
95
100
|
|
|
96
101
|
# After a command's command_ran and save: a /continue that answered the
|
|
@@ -117,9 +122,13 @@ module Samagotchi
|
|
|
117
122
|
# asks again, one before the turn began too (logged, no :turn_failed).
|
|
118
123
|
# The answer's text is carried into the turn as it begins; a turn that
|
|
119
124
|
# never began leaves it to no other.
|
|
125
|
+
# The continue turn is the offer turn's: its origin carries that turn's
|
|
126
|
+
# enqueued_id, so its last_turn, its reply file and its checkpoint mark
|
|
127
|
+
# do too, and a wait on that turn (chi answer) gets its reply.
|
|
120
128
|
def run_continue_turn(command)
|
|
121
129
|
offer = @turn_flow.offer
|
|
122
130
|
resume_from = @turn_flow.resume_from
|
|
131
|
+
enqueued_id = offer_turn_id
|
|
123
132
|
close("answered")
|
|
124
133
|
steer = @continue_steer
|
|
125
134
|
@continue_steer = nil
|
|
@@ -131,8 +140,8 @@ module Samagotchi
|
|
|
131
140
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
132
141
|
return announce_offer(offer)
|
|
133
142
|
end
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
|
|
144
|
+
@run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
|
|
136
145
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
137
146
|
if error
|
|
138
147
|
announce_offer(offer)
|
|
@@ -156,6 +165,11 @@ module Samagotchi
|
|
|
156
165
|
|
|
157
166
|
private
|
|
158
167
|
|
|
168
|
+
# The enqueued_id of the turn the offer stands for: the exhausted
|
|
169
|
+
# turn's last_turn (a canceled continue turn's carries the same id);
|
|
170
|
+
# nil when it ran no input (a reminder turn, a delegate's first).
|
|
171
|
+
def offer_turn_id = @engine.session&.last_turn&.enqueued_id
|
|
172
|
+
|
|
159
173
|
def announce_offer(offer = @turn_flow.offer)
|
|
160
174
|
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
161
175
|
ask
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -23,25 +23,30 @@ module Samagotchi
|
|
|
23
23
|
# Why a request body's delivery field can't be taken, as {error:,
|
|
24
24
|
# detail:} for a 400, or nil when it can. The Bridge and the web's
|
|
25
25
|
# POST /turn both ask this, so they refuse the same values. A missing
|
|
26
|
-
# or unknown string is not refused: it is the default (#parse).
|
|
27
|
-
# is refused until a worker runs it (no worker reads it yet, so it
|
|
28
|
-
# would merge at the next step while the ack said queue).
|
|
26
|
+
# or unknown string is not refused: it is the default (#parse).
|
|
29
27
|
# @param raw [Object] the wire value
|
|
30
28
|
# @return [Hash, nil]
|
|
31
29
|
def self.refusal(raw)
|
|
32
30
|
return nil if raw.nil?
|
|
33
31
|
return { error: "bad_delivery", detail: "delivery must be a string" } unless raw.is_a?(String)
|
|
34
|
-
return QUEUE_UNAVAILABLE if raw == QUEUE
|
|
35
32
|
|
|
36
33
|
nil
|
|
37
34
|
end
|
|
38
35
|
|
|
36
|
+
# The worker sidecar feature (Bridge::FEATURES) of a worker that runs
|
|
37
|
+
# all three values. One without it (before 0.64) ignores QUEUE, or
|
|
38
|
+
# refuses it with a 400 delivery_unavailable, so a sender checks it
|
|
39
|
+
# before sending a queue (SessionManager.deliver_turn, the attached TUI).
|
|
40
|
+
QUEUE_FEATURE = "delivery"
|
|
41
|
+
|
|
42
|
+
# The refusal for a queue a worker without QUEUE_FEATURE would steer
|
|
43
|
+
# (SessionManager.deliver_turn answers it as a 409). P0-P2 workers
|
|
44
|
+
# answer the same error code for a queue, with their own detail.
|
|
39
45
|
QUEUE_UNAVAILABLE = {
|
|
40
|
-
error
|
|
41
|
-
detail
|
|
42
|
-
|
|
46
|
+
"error" => "delivery_unavailable",
|
|
47
|
+
"detail" => "this session's worker predates queueing, so the message would steer the running turn; " \
|
|
48
|
+
"restart it (chi sessions restart ID, or the web's update badge) and send again"
|
|
43
49
|
}.freeze
|
|
44
|
-
private_constant :QUEUE_UNAVAILABLE
|
|
45
50
|
|
|
46
51
|
# Whether it is the one every sender defaulting to a step boundary uses.
|
|
47
52
|
def self.next_step?(value) = parse(value) == NEXT_STEP
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1433,8 +1433,10 @@ module Samagotchi
|
|
|
1433
1433
|
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1434
1434
|
else "client"
|
|
1435
1435
|
end
|
|
1436
|
+
enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
|
|
1436
1437
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1437
1438
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
1439
|
+
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
1438
1440
|
**stop_facts(event))
|
|
1439
1441
|
end
|
|
1440
1442
|
|
|
@@ -9,9 +9,9 @@ require_relative "reasoning_history"
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
# One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
|
|
11
11
|
# holds them by lowercased name; HostRegistry builds its HostEntry from
|
|
12
|
-
# one).
|
|
13
|
-
# SAMAGOTCHI_HOSTS_JSON
|
|
14
|
-
# it back to an equal HostConfig.
|
|
12
|
+
# one). #to_env_h writes every field in the shape of the
|
|
13
|
+
# SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
|
|
14
|
+
# .parse reads it back to an equal HostConfig.
|
|
15
15
|
#
|
|
16
16
|
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
17
17
|
# url: when given); url: the configured url, nil when host/port; transport,
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# A hosts: entry as written (YAML, or a
|
|
29
|
+
# A hosts: entry as written (YAML, or a SAMAGOTCHI_HOSTS_JSON override),
|
|
30
30
|
# or nil when it is disabled (enabled: false) or invalid. An invalid
|
|
31
31
|
# one warns once, naming the entry, the way each reader of a setting
|
|
32
32
|
# does for a bad value (that value is then unset).
|
|
@@ -160,10 +160,10 @@ module Samagotchi
|
|
|
160
160
|
end
|
|
161
161
|
private_class_method :invalid
|
|
162
162
|
|
|
163
|
-
# The entry as a
|
|
163
|
+
# The entry as a SAMAGOTCHI_HOSTS_JSON override carries it (string keys,
|
|
164
164
|
# unset fields left out); .parse(name, to_env_h) gives it back. A url
|
|
165
165
|
# entry travels as its url (host/port come from it); the API key stays
|
|
166
|
-
# in the environment
|
|
166
|
+
# in the environment.
|
|
167
167
|
# @return [Hash{String => Object}]
|
|
168
168
|
def to_env_h
|
|
169
169
|
location = url ? { "url" => url } : { "host" => host, "port" => port }
|
|
@@ -104,8 +104,8 @@ module Samagotchi
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# The entry as written back to config (
|
|
108
|
-
#
|
|
107
|
+
# The entry as written back to config (HostConfig#to_env_h): nil for a
|
|
108
|
+
# bare id.
|
|
109
109
|
def to_config
|
|
110
110
|
config = { "price" => price&.to_config, "served" => served }.compact
|
|
111
111
|
config unless config.empty?
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "idle_client"
|
|
9
9
|
require_relative "idle_target"
|
|
10
|
+
require_relative "image_store"
|
|
10
11
|
require_relative "output_formatter"
|
|
11
12
|
require_relative "steer"
|
|
12
13
|
require_relative "recap_store"
|
|
@@ -53,8 +54,6 @@ module Samagotchi
|
|
|
53
54
|
# separator, while the chat loop writes one message per call.
|
|
54
55
|
TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
|
|
55
56
|
TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
|
|
56
|
-
# The text the web sends for a turn of images alone (images.js turnText).
|
|
57
|
-
IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
58
57
|
DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
|
|
59
58
|
|
|
60
59
|
module_function
|
|
@@ -76,7 +75,7 @@ module Samagotchi
|
|
|
76
75
|
# A delegate child's report (ChildReports) is chi's news, not
|
|
77
76
|
# what the user said.
|
|
78
77
|
content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
|
|
79
|
-
images =
|
|
78
|
+
images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
|
|
80
79
|
[content, *images].reject(&:empty?).join("\n")
|
|
81
80
|
when "model", "assistant"
|
|
82
81
|
strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
|
|
@@ -159,6 +159,14 @@ module Samagotchi
|
|
|
159
159
|
# One-line texts about an image ref ({file:, mime:, width:, height:,
|
|
160
160
|
# bytes:, name:, source:}).
|
|
161
161
|
module ImageRef
|
|
162
|
+
# The text the web sends for a turn of images alone (images.js
|
|
163
|
+
# turnText): "[image: name]" per image, nothing else.
|
|
164
|
+
PLACEHOLDER_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
|
|
165
|
+
|
|
166
|
+
# Whether +text+ is only that placeholder: the images' own lines would
|
|
167
|
+
# name them a second time.
|
|
168
|
+
def self.placeholder_only?(text) = PLACEHOLDER_ONLY_RE.match?(text.to_s)
|
|
169
|
+
|
|
162
170
|
# Claude's rule of thumb (28×28 px per token). About 30% over on the
|
|
163
171
|
# local Qwen, close enough for a label and the context estimate.
|
|
164
172
|
def self.estimated_tokens(ref)
|
|
@@ -9,9 +9,13 @@ module Samagotchi
|
|
|
9
9
|
#
|
|
10
10
|
# id: the turn's id (nil when unknown); started_at / at: iso8601, when the
|
|
11
11
|
# turn began and when this checkpoint was taken; iteration: the loop
|
|
12
|
-
# boundaries it had passed (Integer)
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
# boundaries it had passed (Integer); enqueued_id: the input the turn
|
|
13
|
+
# runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
|
|
14
|
+
# carries its offer turn's), so any process that loads the session can
|
|
15
|
+
# tell which turn is running (ReplyWait.own_turn); nil when it ran none,
|
|
16
|
+
# or a worker from before it wrote one.
|
|
17
|
+
InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
|
|
18
|
+
def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
|
|
15
19
|
|
|
16
20
|
# A saved record (string or symbol keys), an InterruptedTurn as it is;
|
|
17
21
|
# nil for anything else.
|
data/lib/samagotchi/last_turn.rb
CHANGED
|
@@ -12,9 +12,12 @@ module Samagotchi
|
|
|
12
12
|
# children's reports) or "context" (one an attached context source's
|
|
13
13
|
# change started); exhausted/limit: the turn ran out of iterations at
|
|
14
14
|
# limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
|
|
15
|
-
# known.
|
|
15
|
+
# known. enqueued_id: the input's id when the turn ran one (a POST /turn
|
|
16
|
+
# ack's, an input file's), so a wait for a queued message (`chi send
|
|
17
|
+
# --queue --wait`, ReplyWait's turn:) tells its own turn's end from
|
|
18
|
+
# another's. A field may be nil (unset; left out of the file).
|
|
16
19
|
LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
|
|
17
|
-
:error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
|
|
20
|
+
:error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id) do
|
|
18
21
|
def initialize(**fields)
|
|
19
22
|
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
20
23
|
end
|
|
@@ -19,17 +19,24 @@ module Samagotchi
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Append a message to the queue. Nil/empty strings are ignored.
|
|
22
|
-
|
|
22
|
+
# +seq+ is the sender's own order number for it (the REPL numbers every
|
|
23
|
+
# line it takes during a turn, so its leftovers keep send order across
|
|
24
|
+
# this queue and its after-turn list: #drain_entries).
|
|
25
|
+
def push(text, seq: nil)
|
|
23
26
|
text = text.to_s
|
|
24
27
|
return if text.empty?
|
|
25
28
|
|
|
26
|
-
@mutex.synchronize { @messages << text }
|
|
29
|
+
@mutex.synchronize { @messages << [seq, text] }
|
|
27
30
|
nil
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
# Remove and return all queued messages in FIFO order. Non-blocking.
|
|
31
34
|
# @return [Array<String>]
|
|
32
|
-
def drain
|
|
35
|
+
def drain = drain_entries.map(&:last)
|
|
36
|
+
|
|
37
|
+
# #drain with each message's +seq+ (nil when pushed without one).
|
|
38
|
+
# @return [Array(Integer|nil, String)]
|
|
39
|
+
def drain_entries
|
|
33
40
|
@mutex.synchronize do
|
|
34
41
|
drained = @messages
|
|
35
42
|
@messages = []
|