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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/bin/chi +14 -10
- data/docs/architecture.md +10 -2
- data/docs/cli.md +100 -5
- data/docs/configuration.md +26 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -0
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +205 -0
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +92 -53
- data/lib/samagotchi/engine.rb +153 -4
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/testing/fake_executable.rb +6 -16
- data/lib/samagotchi/testing/fake_executable_launcher +6 -0
- data/lib/samagotchi/tool_response.rb +10 -6
- data/lib/samagotchi/tool_runner.rb +25 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -11
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +7 -0
- data/lib/samagotchi/web/public/index.html +145 -1
- data/lib/samagotchi/web/public/info_bar.js +16 -4
- data/lib/samagotchi/web/public/info_bar_model.js +7 -3
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/stage_model.js +6 -5
- data/lib/samagotchi/web/public/stage_view.js +34 -2
- data/lib/samagotchi/web/public/stream_handlers.js +10 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/turn_view.js +16 -4
- data/lib/samagotchi/web/public/working_set.js +612 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +541 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +238 -0
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +11 -2
- 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
|
-
|
|
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` `
|
|
791
|
-
keeps JSON numbers and booleans.
|
|
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
|
|
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,
|
|
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
|
-
##
|
|
278
|
-
|
|
279
|
-
A session
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
-
#
|
|
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:}
|
|
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 "../../
|
|
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,
|
|
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
|
|
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 =
|
|
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?(
|
|
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] =
|
|
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,
|
|
86
|
+
def initialize(path, settings: {}, cost_cap: 1, cost_cap_override: false)
|
|
87
87
|
@path = File.realpath(path)
|
|
88
|
-
@
|
|
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!(@
|
|
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 (
|
|
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 "
|
|
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?(
|
|
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] =
|
|
876
|
+
out[key] = SessionConfig.typed(value)
|
|
877
877
|
end
|
|
878
878
|
end
|
|
879
879
|
|
|
@@ -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"
|
|
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) }
|