samagotchi 0.140.0 → 0.142.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 +64 -1
- data/README.md +1 -1
- data/bin/chi +2 -0
- data/docs/architecture.md +14 -285
- data/docs/bundle-authoring.md +1 -0
- data/docs/cli.md +15 -7
- data/docs/configuration.md +57 -18
- data/docs/context.md +68 -6
- data/docs/guardrails.md +20 -11
- data/docs/hooks.md +9 -7
- data/docs/internals/attached-context.md +226 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +380 -0
- data/docs/internals/bundles.md +1 -0
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +24 -18
- data/docs/internals/participants.md +13 -12
- data/docs/internals/remote-sessions.md +5 -2
- data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
- data/docs/memory.md +31 -1
- data/docs/plugins.md +162 -2
- data/docs/sessions.md +53 -28
- data/docs/system-one.md +122 -0
- data/docs/testing.md +3 -1
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
- data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +74 -14
- data/lib/samagotchi/context_candidate_choice.rb +8 -0
- data/lib/samagotchi/context_candidates_log.rb +25 -1
- data/lib/samagotchi/context_command.rb +32 -9
- data/lib/samagotchi/context_judge.rb +6 -2
- data/lib/samagotchi/context_move.rb +31 -0
- data/lib/samagotchi/context_sources.rb +248 -10
- data/lib/samagotchi/engine.rb +68 -7
- data/lib/samagotchi/extension_load.rb +3 -2
- data/lib/samagotchi/guardrail_wiring.rb +36 -31
- data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
- data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
- data/lib/samagotchi/guardrails_command.rb +3 -3
- data/lib/samagotchi/judgments_command.rb +155 -0
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/llm/chat_loop.rb +74 -10
- data/lib/samagotchi/llm/errors.rb +7 -5
- data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
- data/lib/samagotchi/plugin/api.rb +33 -2
- data/lib/samagotchi/plugin/attached_context.rb +14 -2
- data/lib/samagotchi/plugin/context.rb +54 -1
- data/lib/samagotchi/plugin/judgments.rb +72 -0
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/plugin/peer.rb +53 -0
- data/lib/samagotchi/plugin/sessions.rb +9 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/self_report.rb +36 -0
- data/lib/samagotchi/send_command.rb +3 -1
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/contract.rb +3 -1
- data/lib/samagotchi/session_access/local.rb +24 -3
- data/lib/samagotchi/session_access/remote.rb +19 -8
- data/lib/samagotchi/session_access.rb +26 -6
- data/lib/samagotchi/session_commands.rb +3 -0
- data/lib/samagotchi/session_continue.rb +9 -2
- data/lib/samagotchi/session_manager.rb +13 -2
- data/lib/samagotchi/session_retention.rb +10 -0
- data/lib/samagotchi/side_judgments.rb +12 -1
- data/lib/samagotchi/system_one/answer.rb +63 -11
- data/lib/samagotchi/system_one/approval_label.rb +56 -0
- data/lib/samagotchi/system_one/approval_question.rb +2 -0
- data/lib/samagotchi/system_one/client.rb +43 -17
- data/lib/samagotchi/system_one/fake_backend.rb +1 -1
- data/lib/samagotchi/system_one/http_backend.rb +12 -4
- data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
- data/lib/samagotchi/system_one/judgments.rb +260 -0
- data/lib/samagotchi/system_one/log.rb +4 -2
- data/lib/samagotchi/system_one/recording.rb +117 -0
- data/lib/samagotchi/system_one/runs.rb +179 -0
- data/lib/samagotchi/system_one/runs_store.rb +230 -0
- data/lib/samagotchi/system_one.rb +9 -0
- data/lib/samagotchi/testing/boundary.rb +5 -1
- data/lib/samagotchi/testing/bundle.rb +9 -2
- data/lib/samagotchi/testing/host.rb +2 -1
- data/lib/samagotchi/testing/judgments.rb +185 -0
- data/lib/samagotchi/testing/recorder.rb +5 -0
- data/lib/samagotchi/testing/recording_api.rb +1 -1
- data/lib/samagotchi/tool_output_cut.rb +26 -6
- data/lib/samagotchi/tool_response.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +63 -4
- data/lib/samagotchi/toolkit_suggest.rb +11 -2
- data/lib/samagotchi/tools/memory.rb +28 -0
- data/lib/samagotchi/turn_note.rb +9 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -8
- data/lib/samagotchi/web/public/chains.js +10 -2
- data/lib/samagotchi/web/session_access_routes.rb +7 -2
- data/lib/samagotchi/web/session_create.rb +1 -0
- metadata +17 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Attached context: the mechanism
|
|
2
|
+
|
|
3
|
+
For an agent changing the layer. What a user sees and configures (the commands, the note wording, the wake rules,
|
|
4
|
+
the judge's thresholds, the log and the report) is in [../context.md](../context.md); this page is how the code
|
|
5
|
+
gets a source's text into the prompt, and what each class owns. Not to be confused with `ContextStatus` /
|
|
6
|
+
`ContextUsage` / `ContextWindow` ([context-telemetry.md](context-telemetry.md): token counts) or the LLM context
|
|
7
|
+
strategies ([llm-context-forget.md](llm-context-forget.md)).
|
|
8
|
+
|
|
9
|
+
## From a source to the prompt
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
source (command | push | URL provider) ContextSources: <name>.json + <name>.snapshot.json
|
|
13
|
+
│ ContextPoller (worker thread) runs a command source via ContextFetch;
|
|
14
|
+
│ chi context push / refresh write the snapshot from another process
|
|
15
|
+
▼
|
|
16
|
+
snapshot (text, revision, serial, summary, wake_serial, hint)
|
|
17
|
+
│ Worker#pass, between turns: WorkerInbound#absorb_context
|
|
18
|
+
▼
|
|
19
|
+
ContextAbsorber#pending → Batch of Deliveries (one note per source that is new / changed / failing / gone)
|
|
20
|
+
│ Engine#add_context_note: a tail system message (kind "note", ContextNote), announced as :context_added
|
|
21
|
+
│ session saved, then ContextAbsorber#commit writes subscriptions.json
|
|
22
|
+
▼
|
|
23
|
+
the next request: the note sits after the history; the model reads the text with the context_read tool
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Three rules hold the shape:
|
|
27
|
+
|
|
28
|
+
- **Never the stable prefix.** A note is a tail system message, appended between turns (never mid-turn, never
|
|
29
|
+
before a session's first turn: `conversation_started?`, or `before_turn:` when a prompt is about to run). The
|
|
30
|
+
system prompt carries only the standing line that explains `[CONTEXT NOTE …]`. History stays append-only
|
|
31
|
+
([prompt-caching.md](prompt-caching.md)). Only the pointer (name, why, hint, summary) is in the note; the text is
|
|
32
|
+
read on demand by `Tools::ContextRead`, which reads snapshots only and never runs a command.
|
|
33
|
+
- **Deliver at least once, record after save.** `absorb_context` adds the notes, saves the session, and only then
|
|
34
|
+
`commit`s the subscriptions. A crash in between re-delivers, and `Engine#add_context_note` skips a `note_id` the
|
|
35
|
+
conversation already holds. Ids are deterministic: `ctx-<name>-<serial>-<revision[0,12]>` (the serial makes A, B, A
|
|
36
|
+
three notes), `ctx-<name>-error-<error_since digits>`, `ctx-<name>-detached-<seen[0,12]>`.
|
|
37
|
+
- **Polling is not activity.** The poller and the absorb never hold a worker up (`WorkerIdleExit`).
|
|
38
|
+
|
|
39
|
+
## ContextSources
|
|
40
|
+
|
|
41
|
+
The store (`context_sources.rb`), one root `<state dir>/context/`, apart from the session folders; its header
|
|
42
|
+
comment is the file map and [../context.md](../context.md#storage) lists it for users.
|
|
43
|
+
|
|
44
|
+
- **Records** (`Data`/value objects): `Source` (definition; `cmd` nil = pushed; `movable` nil derives from
|
|
45
|
+
`push?`), `Snapshot` (last good text, `revision` = SHA-256 of the text, `serial` counting revisions, `error` and
|
|
46
|
+
`error_since`, `wake_serial` = the newest revision that asked to wake, `fetched_at` last success, `checked_at` last
|
|
47
|
+
try), `Subscription` (what one session has had: `seen`/`seen_serial`, `read`, `error_seen`, `wakes_at`),
|
|
48
|
+
`Candidate`, `Left`, `Moved`/`Stayed`.
|
|
49
|
+
- **Locations.** A `Location` is a folder of sources: a project's (`projects/<project_key>/`, shared by the
|
|
50
|
+
project's workers) or a session's (`sessions/<id>/`, which also holds `subscriptions.json`, the `muted/` and
|
|
51
|
+
`declined/` markers, `candidates/`, `left.json`). `ContextSources.attached(session_id, project_root:)` is what a
|
|
52
|
+
session sees: its own, then the project's it doesn't shadow. Names are `NAME_RE`; `subscriptions` and `left` are
|
|
53
|
+
reserved (`RESERVED_NAMES`) because their files share the folder.
|
|
54
|
+
- **Writes are file replaces** (`AtomicFile`). `Location#locked(name)` takes `<name>.lock`, the lock a fetch holds
|
|
55
|
+
for its whole command; `record_text` and `record_error` run under it, and `remove` leaves the lock file so a fetch
|
|
56
|
+
still running finds its source gone (`ContextFetch` then writes nothing). Removing a *session* source leaves a
|
|
57
|
+
`declined/<name>` marker (hint inside), which is what stops an auto-attach from bringing it back.
|
|
58
|
+
- **The output contract** is `ContextSources.parse_output` (JSON object with a string `text` is
|
|
59
|
+
`{text, summary, wake, hint}`; anything else is all text; empty or over `TEXT_MAX_BYTES` is `Invalid`), the same for
|
|
60
|
+
a command's stdout and a push. `ContextProviders` resolves a URL to a command source from an installed bundle's
|
|
61
|
+
manifest `context_providers:` (read from its provenance, so the CLI and chi web resolve without loading plugins);
|
|
62
|
+
`{url}` is filled at attach, `{bundle_dir}` and `{ruby}` when the command runs.
|
|
63
|
+
- **Protected.** The root is a `ProtectedPaths` entry: the file tools cannot write it, because a source's command
|
|
64
|
+
runs later outside the guardrails gate with the worker's environment ([../context.md](../context.md#safety)).
|
|
65
|
+
|
|
66
|
+
## ContextPoller and ContextFetch
|
|
67
|
+
|
|
68
|
+
`ContextPoller` is one thread in a worker (`Worker` starts it, `on_change: -> { @waker.wake }`). Every
|
|
69
|
+
`TICK_SECONDS` (5) it walks `ContextSources.attached`, skips pushed and muted sources, and runs each command source
|
|
70
|
+
whose last try (`checked_at`, so a failing source isn't re-run every tick) is older than its interval (`--every`,
|
|
71
|
+
else `context.every_seconds`, floored at `MIN_EVERY_SECONDS` 30). Its first pass uses the floor as the interval, so a
|
|
72
|
+
woken worker's first absorb says what changed while it was away. It calls `on_change` only for `:new` and `:error`.
|
|
73
|
+
|
|
74
|
+
`ContextFetch.fetch` runs one source: `sh -c` in its own process group (a timeout, the output cap or the caller's
|
|
75
|
+
cancel kills the group), cwd the session's folder (a project source: the project root, `project_cwd`), stdin closed,
|
|
76
|
+
`SAMAGOTCHI_CONTEXT_NAME` and `SAMAGOTCHI_CONTEXT_PREVIOUS` in the env. A non-blocking `flock` on `<name>.lock` is
|
|
77
|
+
the arbiter between workers and the CLI: the loser gets `:busy`, and `fresh_within` is checked again under the lock,
|
|
78
|
+
so a project source shared by several workers is fetched once per interval. The `Outcome` status says what happened
|
|
79
|
+
(`:new :same :error :busy :fresh :cancelled :gone`); a cancelled fetch records nothing (a leaving worker isn't the
|
|
80
|
+
source failing). `chi context refresh` and the candidates' first fetch use the same entry.
|
|
81
|
+
|
|
82
|
+
A pushed source has no poller. `chi context push` writes the snapshot from its own process; the worker finds it
|
|
83
|
+
through `ContextAbsorber#store_signature` (names and mtimes of the files in the two folders, `subscriptions.json`
|
|
84
|
+
left out so the worker's own write is not a change), compared on each pass.
|
|
85
|
+
|
|
86
|
+
## ContextAbsorber
|
|
87
|
+
|
|
88
|
+
Pure decision, no I/O of its own beyond the store. `pending` returns `nil` when the signature hasn't moved since the
|
|
89
|
+
last `commit`, else a `Batch` of `Delivery` values (`name`, `note`, `subscription`, `wake_note`, `peer`, `agreed`):
|
|
90
|
+
|
|
91
|
+
| State of source vs the session's `Subscription` | Delivery |
|
|
92
|
+
|---|---|
|
|
93
|
+
| has text, `seen` nil | *Attached* note |
|
|
94
|
+
| `seen != revision` | *Updated* note, `changes = serial - seen_serial` |
|
|
95
|
+
| error newer than `error_seen` | *Couldn't refresh*, once per run of failures |
|
|
96
|
+
| subscription with no source left | *Detached* if the session was told of it, else dropped quietly |
|
|
97
|
+
| no text yet | nothing |
|
|
98
|
+
|
|
99
|
+
A muted source is skipped. Notes name the source as third-party text; the exact wording lives in the class constants
|
|
100
|
+
(`WAKE_LAST_LINE`, `PEER_WAKE_LINE`, `PEER_AGREED_LINE` were tuned in spikes: change one only with an eval).
|
|
101
|
+
|
|
102
|
+
**Wake notes.** A delivery also carries a `wake_note` (the same note with the wake last line) when its source asked
|
|
103
|
+
(`snapshot.wake_serial > sub.seen_serial`, so an earlier asking revision still counts) or, for a first text, only a
|
|
104
|
+
session's own pushed source (`first_text_wakes?`). Whether it *runs* is the worker's, not the absorber's:
|
|
105
|
+
`WorkerInbound#context_wake_for` applies `context.wake`, `WorkerWakes#context_open?` (no continue offer, not paused,
|
|
106
|
+
under `session.max_wakes`, past the start grace), and the per-source `CONTEXT_WAKE_WINDOW` (600 s, from
|
|
107
|
+
`Subscription#wakes_at`); `Worker#pass` calls it only with nothing queued and no exit requested. A failed or
|
|
108
|
+
stopped wake turn is rolled back (`unmark_wake`) to a plain note.
|
|
109
|
+
|
|
110
|
+
**Peers.** `PeerSources` (the peer bundles' in-process declaration, `chi.peer_source`) marks a pushed session source
|
|
111
|
+
as a peer's answers; `peer_label` then needs `context.peer_turns` > 0. A peer delivery has no 10-minute window; its
|
|
112
|
+
budget is `WorkerWakes#peer_open?` (reset only by typed input), and an answer whose summary ends in `VERDICT OK`
|
|
113
|
+
(`peer_agrees?`) wakes once with the closing line, counted toward `session.max_wakes` only.
|
|
114
|
+
|
|
115
|
+
## Candidates
|
|
116
|
+
|
|
117
|
+
`ContextCandidates` holds a session's proposed sources (`Candidate`, from `ctx.context.propose`, stored under
|
|
118
|
+
`candidates/<name>.json` and written once). **Status is derived, never stored**: *attached* while a source of its
|
|
119
|
+
name (or whose hint is its URL) is attached, *declined* while the declined marker names it, else *proposed*.
|
|
120
|
+
|
|
121
|
+
- **Mention.** `mention_in` / `pattern_for` implement the user-facing rules (whole tokens, `#N` ≥ 10 or after
|
|
122
|
+
"PR", branch names ≥ 6 chars with `/ - _`, exact URLs, fenced code blanked). Only `OwnerPrompt` texts count
|
|
123
|
+
(`owner_texts`), so a participant's message, a steer, a note or a reminder never attaches anything; a review
|
|
124
|
+
session skips all of it.
|
|
125
|
+
- **Where it runs.** `before_turn` (from `Worker#run_prompt` before the absorb, and the REPL's turn start): attach
|
|
126
|
+
each proposed candidate the owner's prompts so far mention (`attach` writes a session `Source` through
|
|
127
|
+
`source_for`: a URL resolves through `ContextProviders`, else the candidate's own `cmd`), then `first_fetch` them
|
|
128
|
+
together within `FIRST_FETCH_SECONDS` (10) so the note lands in this turn; cut short, the fetch records nothing and
|
|
129
|
+
the poller tries soon. `after_propose` does the same for a late proposal. The REPL has no absorber running, so
|
|
130
|
+
`deliver_notes` runs one.
|
|
131
|
+
- Forks and continues take no candidates.
|
|
132
|
+
|
|
133
|
+
## ContextJudge and ContextCandidateChoice
|
|
134
|
+
|
|
135
|
+
`ContextJudge` is System 1's "is this session's work about the candidate?" ([../guardrails.md](../guardrails.md#system-1-experimental)).
|
|
136
|
+
It builds a small neutral state per ask (`state(job)`: the candidate's title and describe, `conversation` = the first
|
|
137
|
+
substantive owner prompt and the latest, each bounded at `PROMPT_MAX`, and for a delegate child the parent's attached
|
|
138
|
+
titles), two constant `noul` questions (`about`, `task`: constant so the host's prefix cache holds and nothing a
|
|
139
|
+
plugin wrote is read as instructions), and `ask` writes a `judged` line. Never the branch, the checkout or the
|
|
140
|
+
source's why. `task_p < TASK_MIN` (0.5) leaves the candidate unjudged; `due?` decides asks from the log's `judged`
|
|
141
|
+
lines (never twice at one owner-prompt count; one re-ask after `reask_after_turns`; then done). No answer writes
|
|
142
|
+
nothing, so the next owner prompt asks again.
|
|
143
|
+
|
|
144
|
+
The driver is the Engine, not the judge: `Engine#start_context_judge` (an owner turn's start; skipped for participants,
|
|
145
|
+
review sessions and a Continue) and `judge_late_candidates` (a proposal after the prompts) call `context_judge_pass`.
|
|
146
|
+
It first logs status changes (`ContextCandidatesLog.look`), returns at `mode: off`, builds the due `Job`s, and hands
|
|
147
|
+
them to `SideJudgments` (`:context` kind: one thread, one batch per session at a time). On the model's own local
|
|
148
|
+
server (`system_one_shares_local_host?`) the pass is held in `@context_judge_after_turn` and started after the turn
|
|
149
|
+
(before the prompt warm-up, which waits for it); any other host is asked at once.
|
|
150
|
+
|
|
151
|
+
`ContextCandidateChoice.after_judged` acts on `fields["would"]` in `on` mode only: *attach* → `judge_attach` (a
|
|
152
|
+
session source with `JUDGE_WHY`, no probability in the model-visible why), *offer* → one card (`offer_card`, buttons
|
|
153
|
+
`/context attach NAME` and `/context decline NAME`, guarded by a `carded` log line). It re-checks that the candidate
|
|
154
|
+
is still proposed, since the user may have chosen while it was asked. Cards are shown through the Engine's
|
|
155
|
+
`context_choice` lambda, which holds them while a turn runs (`@context_cards_after_turn`). `choose` is the
|
|
156
|
+
`/context attach|decline` command's body (`SessionCommands`), shared by the card's buttons; it refuses in a review
|
|
157
|
+
session. An attach only writes the session folder: its note comes with the next between-turn delivery, never into a
|
|
158
|
+
running turn.
|
|
159
|
+
|
|
160
|
+
`ContextCandidatesLog` is `context_candidates.jsonl` beside the sessions folder (a `SideJudgments.log`: 0600, one
|
|
161
|
+
line under a lock, the oldest half dropped past 5 MB; written in every mode). `statuses` replays the lines;
|
|
162
|
+
`look` appends `by: "user"` lines for changes it sees (the user's own `chi context add`, a chip's ×); the kinds and the
|
|
163
|
+
report are in [../context.md](../context.md#the-log-and-the-report). `ContextReportCommand` is its only reader besides
|
|
164
|
+
`due?` and `carded?`.
|
|
165
|
+
|
|
166
|
+
## ContextMove and left.json
|
|
167
|
+
|
|
168
|
+
Movable context is the only code path that copies sources between sessions. `ContextMove.call` runs from
|
|
169
|
+
`SessionManager.spawn_session`'s `before_spawn` (a continue: `SessionContinue`; a plugin fork:
|
|
170
|
+
`Plugin::Sessions#fork` with `seed:` = the fork's messages), after the new session and its first note are saved and
|
|
171
|
+
**before its worker starts**, so the first absorb sees the sources. It calls `ContextSources.move`, then writes a
|
|
172
|
+
second note into the new session (`NOTE_SOURCE` "attached context", named by time so it sorts after the first). Best
|
|
173
|
+
effort: a failure is logged (`context_move_failed`) and the session starts without.
|
|
174
|
+
|
|
175
|
+
`ContextSources.move(from:, to:, mode:, seed:)` copies, by plain read (never `Location#locked`: a fetch holds it for
|
|
176
|
+
its whole command), each source for which `Source#movable?` is true: the source file, its snapshot, and a
|
|
177
|
+
`Subscription`; plus every `declined/` and `muted/` marker (the user's choices). Never `candidates/`, lock files, or a
|
|
178
|
+
source the new session already has. The subscription's content is what makes the first note right: a continue's says
|
|
179
|
+
nothing was seen (so the *Attached* note comes) at the snapshot's serial (so an old wake doesn't wake); a fork's is
|
|
180
|
+
what its seed last had, found from the newest note about the source (`seed_note`, via the `note_id` shape above), so
|
|
181
|
+
a source that changed since gets an *Updated* note.
|
|
182
|
+
|
|
183
|
+
`Moved` carries `moved` names and `stayed` (`Stayed`: name, `why` `:pushed` or `:not_movable`). Its `note_text` is the
|
|
184
|
+
second note; `words` is the few words `chi send --new --continues` prints; `to_h` / `from_h` are chi web's
|
|
185
|
+
`moved_context` answer (an older chi web sends none: `from_h(nil)` is nil).
|
|
186
|
+
|
|
187
|
+
**left.json.** For a *continue* the stayed sources are also written to the new link's `sessions/<id>/left.json`
|
|
188
|
+
(`Left`: `name`, `hint`, `from`; the hint is the snapshot's, since a pushed source's hint is where a peer's re-pair
|
|
189
|
+
pushes). `ctx.context.left_behind` reads it (`Plugin::AttachedContext#left_behind`) and `ctx.continues` names the
|
|
190
|
+
previous link. `Plugin::Peer#pair_again` uses both: from its quiet init task it posts one `moved` message to the
|
|
191
|
+
inbox left behind and records a marker under the bundle's `sessions/<id>/`, so it runs once. `Left#peer` /
|
|
192
|
+
`PEER_NAME` exist only to word the second note (a peer's inbox and outbox are one group: "chi asks Claude to pair
|
|
193
|
+
again" vs "Codex pairs again when it next answers"); `PeerSources` decides what a peer is. A fork writes no
|
|
194
|
+
`left.json`.
|
|
195
|
+
|
|
196
|
+
The refusal side: a push into a session that has been continued is refused with the next link's id
|
|
197
|
+
(`context_continued`, chi web 410, `chi context push` exit 1), from `SessionAccess` (`session_access.rb`) and chi web's push route (`web/session_access_routes.rb`).
|
|
198
|
+
|
|
199
|
+
## Gates and config
|
|
200
|
+
|
|
201
|
+
| Where | Gate |
|
|
202
|
+
|---|---|
|
|
203
|
+
| Absorb at all | the conversation has started (`conversation_started?`), or `before_turn: true`; the session isn't stopped on disk |
|
|
204
|
+
| A wake turn | `context.wake`; `WorkerWakes#context_open?`; `CONTEXT_WAKE_WINDOW`; nothing queued and no exit requested; peer: `context.peer_turns`, `peer_open?` |
|
|
205
|
+
| Mention attach | `OwnerPrompt.turn?` (owner author, not a review session) |
|
|
206
|
+
| Judge | `context.candidates.mode` `audit`/`on` (default `off`); a System 1 client configured; not a participant's turn, review session, scratch session or Continue |
|
|
207
|
+
| Command source defined by the agent | `chi-context-cmd` guardrails rule asks, always; a parent agent can't approve it for a child |
|
|
208
|
+
| Writes to the store | `ProtectedPaths` denies the file tools |
|
|
209
|
+
|
|
210
|
+
Keys, all in [../configuration.md](../configuration.md): `context.every_seconds`, `context.wake`,
|
|
211
|
+
`context.peer_turns`, `session.max_wakes`, `context.candidates.{mode,threshold,offer_threshold,reask_after_turns}`.
|
|
212
|
+
|
|
213
|
+
## Data files
|
|
214
|
+
|
|
215
|
+
| File | Written by | Read by |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `context/{projects/<key>,sessions/<id>}/<name>.json` | `chi context add`, a provider, a plugin (`ctx.context.attach`), `ContextCandidates.attach`, `ContextMove` | poller, absorber, `context_read`, chi web |
|
|
218
|
+
| `<name>.snapshot.json` | `ContextFetch`, `chi context push`, `ContextMove` | absorber, `context_read`, chi web chips |
|
|
219
|
+
| `<name>.lock` | `ContextFetch` / `Location#locked` | same |
|
|
220
|
+
| `sessions/<id>/subscriptions.json` | the worker (`commit`, `context_read`) only | absorber, chi web (unread dot) |
|
|
221
|
+
| `sessions/<id>/{muted,declined}/<name>` | `chi context mute`, a removal, `ContextMove` | poller, absorber, candidates |
|
|
222
|
+
| `sessions/<id>/candidates/<name>.json` | `ctx.context.propose` | candidates, judge, `/context` |
|
|
223
|
+
| `sessions/<id>/left.json` | `ContextMove` (continue) | `ctx.context.left_behind` |
|
|
224
|
+
| `context_candidates.jsonl` (beside `sessions/`) | `ContextCandidatesLog` | judge (`due?`), choice (`carded?`), `chi context report` |
|
|
225
|
+
|
|
226
|
+
Specs: `spec/context_*_spec.rb`; a store under test uses an isolated `XDG_STATE_HOME`.
|
|
@@ -26,7 +26,7 @@ Behavior:
|
|
|
26
26
|
- The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
|
|
27
27
|
- A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
|
|
28
28
|
- A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A command is also handed over early when a person's message waits for the turn's next step (the `message_waiting:` probe, the same `Engine#input_waiting?` as `task_wait`'s): after `Execute::EARLY_HAND_OVER_FLOOR_SEC` (3 s: a short command ends first and its result is the better answer), in the next 0.2 s slice. The first line then reads `Still running at the 4s mark: a message from the user is waiting, so the command moved to a background task, not killed.` (the seconds it had run), and the line after `task_id:` starts `Read the message first.`; the rest is the same hand-over, and the task stays watched. Never in `kill` mode (`execute.on_timeout: kill`, a participant's pair turn, the `!cmd` bang): there the probe is not even read and the command runs to its limit and is killed. A command that can't be adopted at that moment (a record that can't be written) isn't killed early: it runs on to its limit. Everything that matches the first line builds on `Execute::HANDED_OVER` (`Still running at the`) and `Execute::HANDED_OVER_AFTER` (`120s limit:` or `4s mark:`): `TaskRuntime.handed_over_pattern`, `ToolActivity::HANDED_OVER_START`, `TaskWatch` (`limit_sec` for the limit's form, `early_sec` for the mark's, which the finish note words as `handed over by execute after 4s: a message from the user was waiting`) and the web's `task_stop.js`; the slow-commands bundle reads only the limit's form, which is the only one that is a timeout. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
|
|
29
|
-
- `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-
|
|
29
|
+
- `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars)).
|
|
30
30
|
- Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
|
|
31
31
|
- `task_get` returns metadata and `output_path`; use `read` for output contents.
|
|
32
32
|
- `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
|