samagotchi 0.100.1 → 0.102.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +14 -10
  4. data/docs/architecture.md +10 -2
  5. data/docs/cli.md +100 -5
  6. data/docs/configuration.md +26 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +205 -0
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +8 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +92 -53
  24. data/lib/samagotchi/engine.rb +153 -4
  25. data/lib/samagotchi/host_keys.rb +35 -18
  26. data/lib/samagotchi/idle_scheduler.rb +16 -1
  27. data/lib/samagotchi/llm_context_edit.rb +32 -10
  28. data/lib/samagotchi/llm_context_forget.rb +12 -3
  29. data/lib/samagotchi/llm_context_view.rb +19 -7
  30. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  31. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  32. data/lib/samagotchi/message_parts.rb +8 -3
  33. data/lib/samagotchi/plugin/context.rb +2 -2
  34. data/lib/samagotchi/prompt.rb +1 -1
  35. data/lib/samagotchi/recap_command.rb +68 -0
  36. data/lib/samagotchi/recap_setup.rb +27 -14
  37. data/lib/samagotchi/send_command.rb +14 -11
  38. data/lib/samagotchi/session.rb +19 -12
  39. data/lib/samagotchi/session_commands.rb +44 -2
  40. data/lib/samagotchi/session_config.rb +411 -0
  41. data/lib/samagotchi/session_setup.rb +9 -9
  42. data/lib/samagotchi/sessions_command.rb +6 -2
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  45. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  46. data/lib/samagotchi/terminal_ui.rb +8 -6
  47. data/lib/samagotchi/testing/bundle.rb +3 -2
  48. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  49. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  50. data/lib/samagotchi/tool_response.rb +10 -6
  51. data/lib/samagotchi/tool_runner.rb +25 -6
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +76 -11
  54. data/lib/samagotchi/web/public/app.js +35 -3
  55. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  56. data/lib/samagotchi/web/public/commands.js +8 -2
  57. data/lib/samagotchi/web/public/data.js +7 -0
  58. data/lib/samagotchi/web/public/index.html +145 -1
  59. data/lib/samagotchi/web/public/info_bar.js +16 -4
  60. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  61. data/lib/samagotchi/web/public/refs.js +10 -1
  62. data/lib/samagotchi/web/public/stage_model.js +6 -5
  63. data/lib/samagotchi/web/public/stage_view.js +34 -2
  64. data/lib/samagotchi/web/public/stream_handlers.js +10 -1
  65. data/lib/samagotchi/web/public/turn_html.js +5 -0
  66. data/lib/samagotchi/web/public/turn_view.js +16 -4
  67. data/lib/samagotchi/web/public/working_set.js +612 -0
  68. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  69. data/lib/samagotchi/web/public/working_set_view.js +541 -0
  70. data/lib/samagotchi/web/server.rb +6 -4
  71. data/lib/samagotchi/worker.rb +9 -6
  72. data/lib/samagotchi/working_set.rb +238 -0
  73. data/lib/samagotchi/working_set_command.rb +119 -0
  74. data/lib/samagotchi/working_set_steer.rb +250 -0
  75. metadata +11 -2
  76. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -0,0 +1,205 @@
1
+ # The working set: what the model really has
2
+
3
+ The stage view's working set (`web.working_set`, [cli.md](../cli.md#the-working-set)) shows the files, memories
4
+ and URLs a session's tool runs brought into the model's context. The page builds the set itself from its tool rows
5
+ (`working_set.js`, phase 1). The server says what the model still has of each item: **live**, **stale** (a file
6
+ changed since the model last saw it) or **forgotten** (nothing of it is still sent), and its size in tokens. This
7
+ page covers the server's side and how the page overlays it.
8
+
9
+ ## Stamps: `tool_stamps`
10
+
11
+ `ToolRunner#run` stamps an ok `read`, `edit` or `write` (`result[:activity][:status] == "ok"`) with its file right
12
+ after the call: `ToolRun#stamp`, `{path:, mtime_ns:, size:}`. The path is the one `FileRef.for` gives (the call's
13
+ path against the session's folder, `File.expand_path`, no realpath). A stat that fails, or that isn't a file's (a
14
+ directory), gives no stamp; nor does any other tool.
15
+
16
+ Both `ToolResponse` shapes save the stamps as `tool_stamps`, one per run, read by the run's place
17
+ (`ToolIds::Ref#run`):
18
+ - `ToolResponse.joined` (the native loop's batch entry): `runs.map(&:stamp)`, only when one is non-nil, like
19
+ `tool_refs`.
20
+ - `ToolResponse.single` (the chat loop's entry for one call): `[stamp]`, a one-stamp array like its `tool_ids`.
21
+
22
+ `tool_stamps` is in `ToolResponse::SAVED_KEYS`, so `ChatLoop#plain` keeps it and no prompt or wire ever renders it
23
+ (the exclusion specs: `spec/llm/chat_loop_spec.rb` "a file run's stamp", `spec/kernel_loop_spec.rb`). After a
24
+ reload the stamp hashes have string keys; the projection reads both. A session saved before the stamps has none, so
25
+ its files are never stale.
26
+
27
+ The model's own successful `edit` or `write` stamps the file after it ran, so it never makes the file stale. Anything
28
+ else that changes it does: a later `execute` (`sed`, `git checkout`), the user's editor, another session. A failed
29
+ edit that changed the file anyway leaves the earlier stamp, so the file shows stale. On a filesystem with 1 s mtimes a
30
+ same-size edit in the same second goes unseen (APFS keeps nanoseconds).
31
+
32
+ ## The projection: `WorkingSet.build`
33
+
34
+ `Samagotchi::WorkingSet.build(conversation, cwd:, strategy:, stat: File.method(:stat))` (`lib/samagotchi/working_set.rb`)
35
+ is pure and returns a `WorkingSet::Snapshot` (`items`, `at`: the conversation's size in entries, the web's ordering
36
+ key). Each `WorkingSet::Item` is `kind` (`file`, `memory`, `url`), `key`, `state` (`live`, `stale`, `forgotten`,
37
+ `dropped`), `tokens`, `ops`, `count` and
38
+ `first_id` (the first run's ToolIds id).
39
+
40
+ **Runs.** It pairs the runs twice with `LLMContextStale.paired_runs`: once on the stored conversation, once on what
41
+ `LLMContextView.new(strategy:).messages(conversation)` sends (stubs keep their `[name]` lead, so they still pair).
42
+ The two are matched by `ref.id`. The calls are the dispatched ones (`Tools::BuiltinCalls.build`), so aliases resolve
43
+ as the loops resolve them. A run that doesn't pair (a "ran as:" corrected call, an output holding the run separator)
44
+ is left out. So is a failed run (`LLMContextStale`'s rule: an `Error` after the `[name]` lead; a denied call's
45
+ `Error:` too) and an interrupted one (`ToolResponse.interrupted_text?`).
46
+
47
+ **Items** follow the page's `rowEntries`:
48
+ - `read`, `edit` and `write` give a file, keyed by its `FileRef` path; `read` adds op `read`, `edit` and `write`
49
+ add op `edit`.
50
+ - `memory_read` gives one memory per name (`Tools::MemoryRead.parse_names`; an empty name, the index listing, gives
51
+ none). A partial read ("a,b" where b is missing: the found bodies, then `Error: memory not found: b`) keeps the
52
+ found names only, and its tokens are split evenly among them.
53
+ - `memory_write` gives the memory `call[:path]`, op `write`. A `remove: true` write stays in the set.
54
+ - `web_fetch` gives the URL with its whitespace collapsed.
55
+
56
+ **Tokens and forgotten.** Only content outputs (`read`, `memory_read`, `web_fetch`) carry tokens and decide
57
+ forgotten. Their tokens are the sent text's length over `TokenUsage::CHARS_PER_TOKEN`, so a stub costs what the stub
58
+ costs and kept lines count themselves. An image read counts its text only. `edit`, `write` and `memory_write`
59
+ outputs are short confirmations whose content sits in the call's arguments, which no strategy cuts: they add ops
60
+ and stamps, never tokens, and they never make an item forgotten.
61
+
62
+ An item is **forgotten** when it has content outputs and none of them is still sent under the session's *current*
63
+ strategy. A content output is still sent when:
64
+ - its sent text equals the stored one, or the stored one with the forget layer's `[#id] ` after its lead;
65
+ - no applied edit of a layer the strategy runs is saved on it (a staged edit doesn't count; an edit saved under a
66
+ strategy the session has since left, such as `/llm-context strategy none`, isn't sent, so the output is live
67
+ again);
68
+ - it is a forget that keeps lines (`forget_outputs keep:`);
69
+ - it is a stale stub an edit made ("superseded by a later edit", `llm_context.stale_edits`): the model wrote the
70
+ file, so what it has of it is current.
71
+
72
+ A stale stub of an old read that a newer read superseded never strikes the file: the newer read is still sent. A file
73
+ only edited or written is never forgotten.
74
+
75
+ **Dropped** is a forgotten item whose content outputs all hold the user's applied `:drop` (the user's layer, sent
76
+ under every strategy: [llm-context-forget.md](llm-context-forget.md)), so it is dropped under any strategy and after
77
+ a switch. An item with one output dropped and another forgotten by the model is forgotten. Under none with no drop
78
+ saved the build doesn't pair the sent conversation at all (the view returns the stored Array).
79
+
80
+ **Stale** is a file item whose latest run left a stamp, and whose file now differs from that stamp in mtime or size,
81
+ or is gone. No stamp, live. A forgotten item stays forgotten whatever its file did.
82
+
83
+ **An item's outputs.** `WorkingSet.outputs_of(conversation, kind:, key:, strategy:, cwd:)` answers an item's
84
+ content outputs (`WorkingSet::Output`: `id`, the tool_response entry's `index`, `derived` for a legacy entry's id,
85
+ and `sent`, the rule above) in conversation order. It walks the runs exactly as `build` does (one shared
86
+ `all_touches`), so what acts on an item's outputs and the projection never disagree: an item is forgotten exactly
87
+ when it has outputs and none is `sent`. An edit-only item, a failed run's file or an unknown key has none; a
88
+ `memory_read` of several names is an output of each name.
89
+
90
+ **Cost.** Two `paired_runs` walks, one view build and one stat per file item. A 500-message session builds in about
91
+ 10 ms on an M5 Ultra and 75 ms on a CI runner; `spec/working_set_spec.rb` only guards against a blow-up (< 400 ms).
92
+
93
+ ## Delivery
94
+
95
+ **The event.** The Engine emits `{type: :working_set_updated, items:, at:}` (`Engine#emit_working_set`):
96
+ - after each `:generation_started`, in `build_stream_event_handler`, from the running turn's conversation
97
+ (`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
98
+ (`@kernel.turn_settings.llm_context`). Both loops start a generation after `apply_llm_context!`, so the set is
99
+ what that request sends;
100
+ - once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
101
+ turn-end apply has run by then);
102
+ - between turns, from `Engine#announce_working_set` (the session's messages, under the event lock; after a
103
+ `/working-set` op): announced
104
+ (`working_set_updated` is in `ANNOUNCEABLE_EVENTS`; held after a command's `command_ran` inside
105
+ `holding_announcements`), under the *next* turn's strategy (`SessionModelSettings#turn_llm_context`, as the
106
+ turn-end warm-up reads it; `TurnSettings` keeps the last turn's, stale after `/llm-context` or `/model`). While a
107
+ turn runs, the strategy is always the turn's.
108
+
109
+ It is emitted only when the set differs from the last one emitted for the session (kept per session id), and never
110
+ while the set is empty, so a tool-free turn on a fresh session emits nothing. One exception: when the last one had
111
+ items and the set is empty now (a rollback removed the tool calls), one empty event goes out, so the page drops the
112
+ stale items; it is remembered, so the next empty request sends none. It goes out whatever the web's flag;
113
+ the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI ignores it.
114
+
115
+ **The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h`
116
+ and `running`. With a live worker it builds from the worker's snapshot messages (as `handle_show` reads them, keys
117
+ symbolized) and the worker's llm ctx chip. Without one it builds from the session file and its saved chip
118
+ (`SessionSetting::LLM_CONTEXT`). The folder is the session's `working_directory`. `running` is true when the
119
+ snapshot has a turn in progress (`current_turn`).
120
+
121
+ **The GET while a turn runs.** A worker's snapshot messages are the session's, not the running turn's, so they lag
122
+ its events. The snapshot also carries `working_set`: the running turn's last `:working_set_updated` (`{items, at}`),
123
+ which the Bridge's `TurnAccumulator#working_set` folds from the event log. It is cleared at `:turn_started` and at
124
+ the turn's end, so it is nil between turns and before the turn emitted one. While a turn runs and it is set, the GET
125
+ answers it as is (no build, no stat): a page opened mid-turn gets the running turn's set at once, and its `at` is the
126
+ event's, so the page takes it (below). Before the turn's first event the turn's set is still the session's (the
127
+ Engine emits only a set that differs from the last one), and the GET builds from the messages as between turns; so
128
+ does one from an older worker without the field, its answer then behind the turn's events, which the page keeps.
129
+
130
+ ## Steering: drop and undrop
131
+
132
+ `/working-set drop KEY…` and `undrop KEY…` (`WorkingSetCommand`, `SessionCommands#working_set`; the web's list posts
133
+ the same lines on `POST /api/sessions/:id/command`) act on the session's messages between turns: the line is
134
+ `mid_turn: :queue`, so one sent while a turn runs waits for its end (bare `/working-set`, the listing, is
135
+ `:anytime`). `WorkingSetSteer` does the work on a copy (`Engine#clone_messages`, then `LLMContextEdit.store`'s new
136
+ edits Hashes), which `Engine#replace_messages` puts in place; the session is saved, the rollback window closes
137
+ (`TurnFlow#note_conversation_changed`, as `!cmd`), the result says `changed: [:messages]` (the web reads the
138
+ conversation again) and `Engine#announce_working_set` re-emits the set, held behind the command's `command_ran`.
139
+
140
+ - **The outputs** are `WorkingSet.outputs_of` the item under the strategy the set is built under
141
+ (`Engine#working_set_strategy`): the ones still `sent` get an applied `:drop` (`by: "user"`), the edit each held
142
+ before kept in its `was:`. A drop's view is the user's layer ([llm-context-forget.md](llm-context-forget.md)).
143
+ The reply weighs the batch (`LLMContextApply.weigh`: what it frees, the tail the server reads again).
144
+ - **undrop** takes the item's drops off and puts each `was:` back. Free when the drop never reached a request (its
145
+ op is still in the pending note), else the reply says the tail.
146
+ - **Refusals** live on one exception, `WorkingSetSteer::Refused` (its `reason` and words): not in the working set;
147
+ nothing to drop (only edited or written; only a legacy entry's derived ids; none of it sent now); already
148
+ dropped; not dropped. `forget_outputs` uses the same words for a dropped output ("dropped by the user"): the
149
+ model can neither forget nor restore it. A `memory_read` of several names is one output of each: dropping one
150
+ name drops that read for the others too.
151
+ - **The note**: the ops since the last turn are one tail system message, `kind: working_set` (in
152
+ `Prompt::ESCAPED_KINDS`), its ops in `working_set_ops`. It is pending while only system messages follow it
153
+ (`WorkingSetSteer.pending_note_index`); a later op joins it, and an inverse op (an undrop of a pending drop) takes
154
+ both off, the note too when nothing is left. A turn that sends it makes it history; the next op starts a new one.
155
+
156
+ The projection shows a dropped item as `dropped` (below), the GET and the event alike.
157
+
158
+ ## The page's overlay
159
+
160
+ The page keeps its row-derived items and their `at` ordinals (the step tags' jump path), and overlays the server's
161
+ `state` and `tokens` by key (`working_set.js overlay`):
162
+ - files by absolute path (the row's `ref.path`; a title fallback won't match, so it stays live);
163
+ - memories by name;
164
+ - URLs by the row's key, which is its params preview with whitespace collapsed, cut at 80 characters with a "…".
165
+ The "…" is stripped and the rest is matched as a prefix of the server's URL.
166
+
167
+ An item the server doesn't know yet (a running call) stays live. Known mismatch: live rows' refs resolve against the
168
+ worker's `Dir.pwd` (a real path), the server against `working_directory`, so a session under `/tmp` can miss
169
+ (`/private/tmp`).
170
+
171
+ `working_set_view.js` keeps one server snapshot per session; `working_set.js newerSnapshot` picks which. `at` can't
172
+ order them: a rollback (a canceled turn's `end_turn`, a failed turn's `TurnFlow` restore) shortens the conversation,
173
+ so the right set can have the smaller `at`. The page orders by what it saw instead:
174
+ - an event is always taken. The stream delivers them in the order the Engine built them, so the newest event is the
175
+ server's latest set, a rollback's smaller (or empty) one too;
176
+ - a GET's answer is dropped when an event came while it was on its way (the view counts the events it took and
177
+ compares the count at the ask with the count at the answer): the event may be newer;
178
+ - else it is taken, unless it is `running` and its `at` is smaller than the kept one's (it is behind the running
179
+ turn's events: a GET built from the messages). A `running` GET answered from the turn's last event has that
180
+ event's `at`, so it is taken. An idle GET after a rollback the page missed (a resync) or after a file changed wins at any `at`.
181
+
182
+ No server sequence: the GET is built in `chi web` from the worker's snapshot or the file, the events in the worker,
183
+ and a worker's event count starts over when it respawns; the page's own order needs neither. The page asks for it when it seeds a
184
+ session's set (load, resync) and when the list opens (a file changed while the session sat idle). The stream handler
185
+ passes `working_set_updated` to it, and does nothing when the flag is off. A new snapshot redraws:
186
+ - the session bar's chip and the open list;
187
+ - the footprints, in place (`refreshFootprints`, the same node; a `data-ws-sig` of the items' states skips an
188
+ unchanged one);
189
+ - the rail (`onServer`, the stage's `scheduleRefresh`).
190
+
191
+ A state is the item's state now, not when its turn ran. A state change never refolds and never moves a line: the
192
+ rail's line key carries the label's state and its forgotten count, which are patched in place.
193
+
194
+ **Steering from the page.** The list's `drop`/`undrop` (and `undo`, `undo all`) post `/working-set` lines on the
195
+ command route (`createWorkingSet`'s `sendLine`). Pending ops are the queued lines (`command_queued` with `waits:
196
+ "turn_end"`, any client's, and the snapshot's `queued_commands` replay after a reload), parsed and netted by
197
+ `working_set.js pendingSteers` until each one's `command_ran`; a seed clears them (the replay brings them back).
198
+ A steer line names a file by its absolute path, a memory as `memory:NAME`, a URL by the server's full key. `dropped`
199
+ is in `STATES`: struck like forgotten, and a group is `dropped` when all its members are.
200
+
201
+ **Looks** (design spec §3, §5): a stale line is faded (`opacity: .55`); a forgotten one is struck in `--text-faint`
202
+ at `opacity: .5`. A group shows `✂n` for its forgotten members. It is struck only when all its members are
203
+ forgotten, and faded when all are stale or forgotten (`groupState`). The list's ops text says `stale` or `forgotten`
204
+ in words (colour is never alone) and shows `~1.2k` tokens per row (`tokensText`). A footprint adds `n forgotten`,
205
+ struck. Titles add `(stale)` or `(forgotten)` per member.
data/docs/plugins.md CHANGED
@@ -780,15 +780,18 @@ The Engine itself is never handed to a plugin.
780
780
 
781
781
  A bundle's settings are config.yml's, for every session. A setting a session may have of its own (a child's cap,
782
782
  say) is one the manifest lists in `session_settings:`; `chi --set BUNDLE.KEY=VALUE` and `chi send --new --set …`
783
- then give it to a new session, saved with it (a respawn, a fork and a continued link keep it; a delegate child
783
+ (which also take a few core keys, `--set recap.enabled=false`: see
784
+ [Per-session settings](configuration.md#per-session-settings)) then give it to a new session, saved with it (a respawn, a fork and a continued link keep it; a delegate child
784
785
  doesn't). chi refuses a bundle that isn't installed (or whose plugin doesn't load) and a key the manifest doesn't
785
786
  list, so a typo isn't saved. A manifest that types a key (`session_settings: {cap: number}`: `number`, which
786
787
  takes off too, `boolean`, `string`, or `any`) gets only a value of that type: `--set my-bundle.cap=five` is refused,
787
788
  exit 2, as a key it doesn't list is (a mapping needs a chi newer than 0.91.0). Values are typed simply: an integer (`3`, `-1`) or a decimal (`2.5`, `.5`, `1e1`)
788
789
  is a number, `true`, `yes` and `on` are true, `false`, `no` and `off` are false (any case, as config.yml's YAML reads
789
790
  them), and anything else stays the text as given (`010`, `0x10`, `3:30`, `y`, `null`; YAML would read the first
790
- three as numbers and `null` as nothing); a key typed `string` keeps its text as given (`yes`, `5`, `1.10`); `POST /api/sessions` `bundle_settings` types its strings the same way and
791
- keeps JSON numbers and booleans. `ctx.settings` is the config's section with the session's values laid over it (one level: a session's
791
+ three as numbers and `null` as nothing); a key typed `string` keeps its text as given (`yes`, `5`, `1.10`); `POST /api/sessions` `settings` (`{"my-bundle.cap": 3}`) types its strings the same way and
792
+ keeps JSON numbers and booleans. A name that is a core session setting is always chi's: a manifest whose
793
+ `session_settings:` key `K` makes `NAME.K` one (a bundle `recap` offering `enabled`) is refused at install, and
794
+ `chi bundle check` reports it as an `error clash`, in the same words. `ctx.settings` is the config's section with the session's values laid over it (one level: a session's
792
795
  `cap` replaces the config's `cap`). Read such a key from `ctx.settings` where it is used, not in
793
796
  `initialize(settings)`: that runs once at load, before any session, and gets the config only.
794
797
 
data/docs/sessions.md CHANGED
@@ -151,6 +151,13 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
151
151
  nothing). The user's last message is still unanswered.]`, or `[SYSTEM: the previous turn's answer was refused by
152
152
  the provider (cyber). …]` when the provider refused it.
153
153
 
154
+ After you drop or undrop an item of the working set between turns (`/working-set drop KEY`, the web list's
155
+ `drop`; [cli.md](cli.md#the-working-set)), the next turn starts with one line about it (`kind: working_set`,
156
+ escaped like a context note; the UIs don't show it): `[WORKING SET: since your last turn the user dropped
157
+ lib/cart.rb from your context: you are sent a stub instead of its outputs; read it again only if the task needs
158
+ it.]`. Every op since the last turn joins that one line, and an undrop before the next turn takes its drop off it.
159
+ The drop itself is saved on the entries (`edits`, kind `drop`), so `--resume`, rollbacks and forks keep it.
160
+
154
161
  When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
155
162
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
156
163
  and range reads over full-file dumps]`, once per rise; see [context telemetry](internals/context-telemetry.md).
@@ -211,9 +218,9 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
211
218
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
212
219
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `removed` (a `--queue --wait` message taken back before it ran), `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` (`worker_stopped`: the turn waited for died with its worker and the next worker settled it) and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
213
220
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
214
- - `--set BUNDLE.KEY=VALUE` (with `--new`, repeatable) gives the new session a bundle setting of its own, as `chi --set` does (see *Bundle settings* below): `chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap. A bundle or key the installed bundles don't offer, or a value of another type than the manifest gives the key (`cost-cap.cap=five`), is a usage error (exit 2), as is `--set` without `--new`.
221
+ - `--set KEY=VALUE` (with `--new`, repeatable) gives the new session a setting of its own, as `chi --set` does (see *Session settings* below): a core key a session may set (`--set recap.enabled=false`) or a bundle's `BUNDLE.KEY` (`chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap). A key no session sets (`guardrails.mode`), a bundle or key the installed bundles don't offer, or a value of another type than the key takes (`recap.enabled=maybe`, `cost-cap.cap=five`) is a usage error (exit 2), as is `--set` without `--new`.
215
222
  - `--parent-answers LEVEL` (with `--new`) gives the new session its own `guardrails.parent_answers` (`hard`, `standard`, `soft`), tighten-only: config.yml's applies when it is stricter, with `chi send: warning: --parent-answers soft is looser than config.yml's …`. See *Parent answers* below.
216
- - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit, bundle settings and parent answers, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool`, `--set` and `--parent-answers` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
223
+ - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit, session settings and parent answers, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool`, `--set` and `--parent-answers` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
217
224
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
218
225
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
219
226
 
@@ -274,15 +281,25 @@ fork keep it, and a `delegate` child starts with its parent's as it is then (a l
274
281
  it). A value that isn't a level reads as `hard`. `/parent-answers` changes it; loosening it is yours alone (see
275
282
  [Guardrails](guardrails.md#answers-from-a-parent-agent)).
276
283
 
277
- ## Bundle settings
278
-
279
- A session file's `bundle_settings` (`{"cost-cap": {"cap": 3}}`, `{}` for none and for a session from before them)
280
- holds the bundle settings it was created with: `chi --set BUNDLE.KEY=VALUE`, `chi send --new --set …`, or `POST
281
- /api/sessions` `bundle_settings` (an object of the same shape, checked the same way; `400 invalid_bundle_settings`
282
- otherwise). They never change after: a respawned or woken worker reads the same, and a continue (`continue →`,
283
- `chi send --new --continues`) and a fork (`ctx.sessions.fork`) copy them, as they copy the toolkit. A delegate
284
- child starts without (a bundle has its own setting for children, such as cost-cap's `child:`). A bundle's plugin
285
- reads them in `ctx.settings`, over config.yml's `bundles:` ([Per-session settings](plugins.md#per-session-settings)).
284
+ ## Session settings
285
+
286
+ A session's own settings are what `chi --set`, `chi send --new --set …` or `POST /api/sessions` `settings` gave it
287
+ at creation. One changes live: `/recap on|off|default` (any UI, or `chi send -m "/recap off" ID`) sets or removes
288
+ `recap.enabled` and the worker saves it, so a respawn keeps it. The file keeps them in two fields:
289
+
290
+ - `config_settings` (`{"recap.enabled": false}`): the core keys a session may set (`recap.enabled`,
291
+ `recap.sentences`; see [Per-session settings](configuration.md#per-session-settings)), typed by their key. They go
292
+ first, over the CLI, the environment and config.yml. A value the file holds for a key that is no longer settable, or
293
+ of another type, is dropped when the file is read.
294
+ - `bundle_settings` (`{"cost-cap": {"cap": 3}}`): a bundle's settings, which its plugin reads in `ctx.settings`, over
295
+ config.yml's `bundles:` ([Per-session settings](plugins.md#per-session-settings)).
296
+
297
+ Both are `{}` for none and for a session from before them. `POST /api/sessions` takes them flat, as `--set` names
298
+ them (`"settings": {"recap.enabled": false, "cost-cap.cap": 3}`), checked the same way (`400 invalid_settings`
299
+ otherwise; `settings` beside `continues` is refused). A respawned or woken worker reads the same, and a continue
300
+ (`continue →`, `chi send --new --continues`) and a fork (`ctx.sessions.fork`) copy them, as they copy the toolkit. A
301
+ delegate child starts without (a bundle has its own setting for children, such as cost-cap's `child:`), so its recap
302
+ follows config.yml. `/stats` names them on a `settings:` line, and `chi sessions stats --format json` as `settings`.
286
303
 
287
304
  ## Delegating
288
305
 
data/docs/testing.md CHANGED
@@ -23,6 +23,13 @@ style pass: add a rule only for a bug class it would have caught. It runs in abo
23
23
  `rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
24
24
  global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
25
25
 
26
+ CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
27
+ (`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
28
+ to it with `PW_TEST_CONNECT_WS_ENDPOINT`, and traces and the report stay on the runner. Nothing is installed with
29
+ apt. The tag is the installed `@playwright/test`'s version, read at run time, so bumping it in `package-lock.json`
30
+ moves the image too (`spec/ci_playwright_image_spec.rb` keeps a pinned tag out of the workflow). To run the suite
31
+ that way locally: `npx playwright run-server --port 3333`, then `PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3333/ npm run e2e`.
32
+
26
33
  No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
27
34
  clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
28
35
 
@@ -34,7 +41,7 @@ exception's text changes between Ruby versions: write the string yourself).
34
41
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
35
42
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
36
43
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
37
- `chains.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `dock_reserve.js`,
44
+ `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `dock_reserve.js`,
38
45
  `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
39
46
  never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
40
47
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
@@ -35,6 +35,7 @@ module Samagotchi
35
35
  @merged_origins = []
36
36
  @recap = nil
37
37
  @continue_offer = nil
38
+ @working_set = nil
38
39
  end
39
40
 
40
41
  def call(event)
@@ -192,6 +193,17 @@ module Samagotchi
192
193
  end
193
194
  private_class_method :with_images
194
195
 
196
+ # The running turn's working set: its last :working_set_updated
197
+ # ({items:, at:}, Engine#emit_working_set). The session's kept messages
198
+ # lag the running turn, so this is what a mid-turn read of the set
199
+ # answers (Web::App#handle_working_set).
200
+ # @return [Hash, nil] a copy; nil with no turn running, or before the
201
+ # turn emitted one (its set is then the session's: the Engine emits
202
+ # only a set that differs from the last one)
203
+ def working_set
204
+ @mutex.synchronize { @working_set && Marshal.load(Marshal.dump(@working_set)) }
205
+ end
206
+
195
207
  # @return [String, nil] the last idle recap, until a turn makes it stale
196
208
  def recap
197
209
  @mutex.synchronize { @recap }
@@ -249,6 +261,7 @@ module Samagotchi
249
261
  parts: [], pending_question: nil }
250
262
  @turn[:resumed] = true if event[:resumed]
251
263
  @tool_started_at = {}
264
+ @working_set = nil
252
265
  @turn[:images] = event[:images] if event[:images]
253
266
  @turn[:cut_after] = event[:cut_after] if event[:cut_after]
254
267
  when :input_merged
@@ -261,6 +274,7 @@ module Samagotchi
261
274
  if Events::TURN_END.include?(type)
262
275
  @turn = nil
263
276
  @merged_origins = []
277
+ @working_set = nil
264
278
  elsif event[:event_seq]
265
279
  @turn[:event_seq] = event[:event_seq]
266
280
  end
@@ -321,6 +335,8 @@ module Samagotchi
321
335
  Array(event[:steers]).each do |steer|
322
336
  parts << { kind: "steer", iteration: event[:iteration], source: steer[:source].to_s, text: steer[:text].to_s.dup }
323
337
  end
338
+ when :working_set_updated
339
+ @working_set = { items: event[:items], at: event[:at] }
324
340
  when :reminder_injected
325
341
  parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
326
342
  when :question_requested
@@ -246,7 +246,10 @@ module Samagotchi
246
246
  # with its own).
247
247
  # The commands queued for after the running turn (queued_commands,
248
248
  # TurnAccumulator#queued_commands) too.
249
- # @return [Hash] {messages:, current_turn:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
249
+ # working_set: the running turn's last :working_set_updated ({items:,
250
+ # at:}, TurnAccumulator#working_set), which the messages lag; nil
251
+ # between turns and before the turn emitted one.
252
+ # @return [Hash] {messages:, current_turn:, working_set:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
250
253
  # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
251
254
  def snapshot
252
255
  @engine.synchronize_events do
@@ -254,6 +257,7 @@ module Samagotchi
254
257
  {
255
258
  messages: @engine.messages_checkpoint,
256
259
  current_turn: current_turn_with_generation,
260
+ working_set: @accumulator.working_set,
257
261
  queued: @accumulator.queued,
258
262
  queued_commands: @accumulator.queued_commands,
259
263
  recap: @accumulator.recap,
@@ -571,10 +575,12 @@ module Samagotchi
571
575
 
572
576
  # /recap in an attached TUI: the saved recap, and a new one asked for at
573
577
  # once (it arrives as :recap_ready). Answers mid-turn too.
574
- # 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
578
+ # 200 {enabled:, saved:, request:, min_user_turns:}; off, session_off:
579
+ # whether the session's own setting (/recap off) turned it off.
580
+ # Returns [headers, status, body].
575
581
  def handle_recap(_session_id, _body = nil)
576
582
  recap = @engine.recap
577
- return [{}, 200, { enabled: false }] unless recap
583
+ return [{}, 200, { enabled: false, session_off: @engine.recap_override == false }] unless recap
578
584
 
579
585
  saved = @engine.saved_recap
580
586
  [{}, 200, { enabled: true, saved: saved, request: @engine.request_recap.to_s, min_user_turns: recap.min_user_turns }]
@@ -78,7 +78,7 @@ module Samagotchi
78
78
  check_requires_chi(manifest)
79
79
  check_trust(manifest)
80
80
  check_clash(manifest)
81
- check_install(manifest) unless missing || placeholders?
81
+ check_install(manifest) unless missing || placeholders? || manifest.session_settings_clash
82
82
  check_boundary(manifest)
83
83
  check_tests
84
84
  end
@@ -245,6 +245,10 @@ module Samagotchi
245
245
  # Before the sandbox: the user's installed bundles and chi's shipped ones.
246
246
  def check_clash(manifest)
247
247
  name = manifest.name
248
+ # A session setting --set would read as chi's own: the install refuses it.
249
+ if (clash = manifest.session_settings_clash)
250
+ add(:error, :clash, "manifest.yml", clash, line: manifest_line("session_settings"))
251
+ end
248
252
  shipped = MemoryBundle::Listing.shipped.map(&:name) + [MemoryBundle::SystemBundle::BUNDLE_NAME]
249
253
  if shipped.include?(name) && !shipped?
250
254
  add(:warn, :clash, "manifest.yml", "chi ships a bundle named #{name}; installing this one replaces chi's",
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "../../cli/exit"
4
4
  require_relative "../../cli/flags"
5
- require_relative "../../bundle_session_settings"
5
+ require_relative "../../session_config"
6
6
  require_relative "../../thinking"
7
7
  require_relative "sandbox"
8
8
  require_relative "run"
@@ -73,7 +73,7 @@ module Samagotchi
73
73
  render = Render.new(stdout: @stdout, stderr: @stderr)
74
74
  sandbox = nil
75
75
  begin
76
- sandbox = Sandbox.new(path, bundle_settings: settings.bundle, cost_cap: settings.cost_cap,
76
+ sandbox = Sandbox.new(path, settings: settings.bundle, cost_cap: settings.cost_cap,
77
77
  cost_cap_override: settings.cost_cap_override)
78
78
  sandbox.install
79
79
  result = Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
@@ -133,7 +133,7 @@ module Samagotchi
133
133
  nil
134
134
  end
135
135
 
136
- # The --set KEY=VALUE pairs, typed as BundleSessionSettings.typed:
136
+ # The --set KEY=VALUE pairs, typed as SessionConfig.typed:
137
137
  # cost-cap.cap is the run's cap, the others the tried bundle's.
138
138
  # @return [Settings, nil] nil after saying what's wrong
139
139
  def parse_settings(pairs)
@@ -143,18 +143,18 @@ module Samagotchi
143
143
  pairs.each do |pair|
144
144
  key, sep, value = pair.partition("=")
145
145
  if key == COST_CAP_KEY && !sep.empty?
146
- cost_cap = BundleSessionSettings.typed(value)
146
+ cost_cap = SessionConfig.typed(value)
147
147
  override = true
148
148
  next if (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
149
149
 
150
150
  @stderr.puts "chi bundle try: --set #{COST_CAP_KEY} takes a non-negative number or off"
151
151
  return nil
152
152
  end
153
- unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
153
+ unless !sep.empty? && key.match?(SessionConfig::KEY)
154
154
  @stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
155
155
  return nil
156
156
  end
157
- bundle[key] = BundleSessionSettings.typed(value)
157
+ bundle[key] = SessionConfig.typed(value)
158
158
  end
159
159
  Settings.new(bundle: bundle, cost_cap: cost_cap, cost_cap_override: override)
160
160
  end
@@ -83,9 +83,9 @@ module Samagotchi
83
83
 
84
84
  attr_reader :root, :config_home, :state_home, :work_dir
85
85
 
86
- def initialize(path, bundle_settings: {}, cost_cap: 1, cost_cap_override: false)
86
+ def initialize(path, settings: {}, cost_cap: 1, cost_cap_override: false)
87
87
  @path = File.realpath(path)
88
- @bundle_settings = bundle_settings
88
+ @settings = settings
89
89
  @cost_cap = cost_cap
90
90
  @cost_cap_override = cost_cap_override
91
91
  read_manifest
@@ -153,7 +153,7 @@ module Samagotchi
153
153
  data["context"]&.delete("sources")
154
154
  data["bundles"] = { "cost-cap" => { "cap" => @cost_cap } }
155
155
  data["bundles"][@manifest.name] ||= {}
156
- data["bundles"][@manifest.name].merge!(@bundle_settings)
156
+ data["bundles"][@manifest.name].merge!(@settings)
157
157
  data["bundles"]["cost-cap"]["cap"] = @cost_cap if @cost_cap_override
158
158
  @layout.write_config(data)
159
159
  end
@@ -863,17 +863,17 @@ module Samagotchi
863
863
  Try::Command.new(stdout: @stdout, stderr: @stderr).run(parsed)
864
864
  end
865
865
 
866
- # --set KEY=VALUE values as typed (BundleSessionSettings.typed), or nil
866
+ # --set KEY=VALUE values as typed (SessionConfig.typed), or nil
867
867
  # after saying what's wrong.
868
868
  def check_settings(pairs, label:)
869
- require_relative "bundle_session_settings"
869
+ require_relative "session_config"
870
870
  pairs.each_with_object({}) do |pair, out|
871
871
  key, sep, value = pair.partition("=")
872
- unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
872
+ unless !sep.empty? && key.match?(SessionConfig::KEY)
873
873
  @stderr.puts "chi bundle #{label}: --set takes KEY=VALUE, not #{pair}"
874
874
  return nil
875
875
  end
876
- out[key] = BundleSessionSettings.typed(value)
876
+ out[key] = SessionConfig.typed(value)
877
877
  end
878
878
  end
879
879
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.100.1
3
+ version: 0.102.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:
@@ -96,6 +96,10 @@ module Samagotchi
96
96
  end
97
97
  private_class_method :listed_mid_turn
98
98
 
99
+ # A name is taken once, with one exception: core may give a name two
100
+ # entries, one the UIs run (local) and one the session runs, each with
101
+ # its own match (/recap shows in a UI, /recap off runs in the worker).
102
+ # #listing then has the name twice, /help and Tab once.
99
103
  # @param anytime [Boolean] mid_turn: :anytime (a bundle's command)
100
104
  # @param mid_turn [Symbol, #call, nil] see Entry; nil takes +anytime+
101
105
  # @param match [#call, nil] the default matches the name alone or the
@@ -104,7 +108,7 @@ module Samagotchi
104
108
  # @return [Entry]
105
109
  def register(name, description, id: nil, anytime: false, mid_turn: nil, local: false, uis: nil, match: nil,
106
110
  source: "core", annotations: [], &handler)
107
- raise ArgumentError, "command #{name} is already registered" if @entries.any? { |entry| entry.name == name }
111
+ raise ArgumentError, "command #{name} is already registered" unless name_free?(name, local: local, source: source)
108
112
 
109
113
  entry = Entry.new(id: id || name.delete_prefix("/").to_sym, name: name, description: description,
110
114
  mid_turn: mid_turn || (anytime ? :anytime : :refuse), local: local, uis: uis, match: match || default_match(name),
@@ -203,6 +207,13 @@ module Samagotchi
203
207
  @entries << entry
204
208
  end
205
209
 
210
+ def name_free?(name, local:, source:)
211
+ taken = @entries.select { |entry| entry.name == name }
212
+ return true if taken.empty?
213
+
214
+ source == "core" && taken.size == 1 && taken.first.source == "core" && !taken.first.local != !local
215
+ end
216
+
206
217
  def default_match(name)
207
218
  after = /\A#{Regexp.escape(name)}\s/
208
219
  ->(text) { text == name || after.match?(text) }