samagotchi 0.159.0 → 0.160.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/architecture.md +5 -6
  4. data/docs/cli.md +1 -1
  5. data/docs/guardrails.md +21 -2
  6. data/docs/internals/attached-context.md +4 -2
  7. data/docs/internals/bridge.md +7 -0
  8. data/docs/internals/engine.md +7 -1
  9. data/docs/memory.md +41 -13
  10. data/docs/scheduling.md +16 -3
  11. data/docs/sessions.md +3 -1
  12. data/lib/samagotchi/bridge.rb +11 -2
  13. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  14. data/lib/samagotchi/bundle_command.rb +1 -0
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  17. data/lib/samagotchi/client_id.rb +4 -0
  18. data/lib/samagotchi/command_intake.rb +56 -0
  19. data/lib/samagotchi/context_absorber.rb +6 -3
  20. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  21. data/lib/samagotchi/context_candidates.rb +11 -8
  22. data/lib/samagotchi/context_command.rb +9 -3
  23. data/lib/samagotchi/context_poller.rb +5 -2
  24. data/lib/samagotchi/context_sources.rb +6 -4
  25. data/lib/samagotchi/engine/events.rb +13 -2
  26. data/lib/samagotchi/engine/guardrails.rb +3 -4
  27. data/lib/samagotchi/engine.rb +1 -0
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/events.rb +71 -0
  30. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  31. data/lib/samagotchi/hook_routes.rb +0 -1
  32. data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
  33. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  34. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  35. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  36. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  37. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  38. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  39. data/lib/samagotchi/memory_bundle/status.rb +2 -2
  40. data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
  41. data/lib/samagotchi/memory_orphans.rb +14 -4
  42. data/lib/samagotchi/memory_paths.rb +29 -6
  43. data/lib/samagotchi/memory_usage.rb +23 -12
  44. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  45. data/lib/samagotchi/plugin/sessions.rb +4 -1
  46. data/lib/samagotchi/project_command.rb +58 -20
  47. data/lib/samagotchi/project_id.rb +102 -0
  48. data/lib/samagotchi/project_id_clash.rb +62 -0
  49. data/lib/samagotchi/project_init.rb +214 -10
  50. data/lib/samagotchi/project_scope.rb +11 -2
  51. data/lib/samagotchi/schedules.rb +99 -39
  52. data/lib/samagotchi/session.rb +17 -2
  53. data/lib/samagotchi/session_access/local.rb +8 -5
  54. data/lib/samagotchi/session_access/remote.rb +2 -1
  55. data/lib/samagotchi/session_access.rb +4 -1
  56. data/lib/samagotchi/session_commands.rb +7 -3
  57. data/lib/samagotchi/session_continue.rb +10 -6
  58. data/lib/samagotchi/session_manager/spawn.rb +9 -2
  59. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  60. data/lib/samagotchi/state_format.rb +118 -0
  61. data/lib/samagotchi/tools/context_read.rb +7 -2
  62. data/lib/samagotchi/tools/delegate.rb +10 -2
  63. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  64. data/lib/samagotchi/update_command.rb +2 -2
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +5 -4
  67. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  68. data/lib/samagotchi/worker.rb +18 -6
  69. data/lib/samagotchi/worker_inbound.rb +21 -2
  70. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab720ab347312f152fc99a03ca8cd423a640de2c9b4221ccc70803396a2d8c09
4
- data.tar.gz: 313bbe5c7cce008df50c73de233557824723566db8449a8e3a04ff01e85343f9
3
+ metadata.gz: 9f4ca470372c140d0bcdc30583312ee1139b05b878a8e0f4688f9401a7af3700
4
+ data.tar.gz: dfd3b0bf49a29758ea645bdf4ac6614c9a93610f013e7517d75d8eefe4b3e3ab
5
5
  SHA512:
6
- metadata.gz: 4c02cc469245adaa130c74b1e10978c11b51e85f7ff8c2fe9c27d0de68682d0452f07d9dec7f9100c032dac9cf7357bff81fc70586c9c6cb1e99ab0327c68d56
7
- data.tar.gz: 7ffaacdca84dc1396e5f6edd1160b54e063daa38b71d9aa7c36d7619495d26271606efd277024f344e18043ed2112f3343ea127560438bea0f5480b98df617f3
6
+ metadata.gz: 18bd973f0dfffd58845df4341e41a2f3d32292a4d158e866cf76d907b64d9aac4539bacfc0a9cbc4a81a05b5a3bcc81cf8fec8413f52deb0a95c7141a6235082
7
+ data.tar.gz: 2c2aaf26b58566064737487ae20dfc7037a752f20e426c80375ca0068e14f31e85ac2bc5927522c500dd4622c17f7696e0e93aadf3697cb79cf6b7d08e00669d
data/CHANGELOG.md CHANGED
@@ -8,6 +8,44 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.160.0] - 2026-11-09
12
+
13
+ ### Added
14
+
15
+ - A folder made a chi project with `chi project init` keeps its memories when you move or rename it (an `id:` in
16
+ .chi/project.yml); rerun `chi project init` in an older project to adopt one, `--new-id` to split a copied folder.
17
+
18
+ ### Changed
19
+
20
+ - `schedules.json` keeps what a newer chi wrote (its format version, unknown fields, entries this chi can't read) when
21
+ an older chi rewrites it.
22
+ - The `SessionManager.run_session_loop` forwarder for older boot strings is gone: a chi web or terminal still
23
+ running 0.158.0 or older must be restarted after updating, because a worker it spawns names that entry.
24
+
25
+ ### Fixed
26
+
27
+ - A message that starts with `!` runs as a shell command only when a person sends it (a web tab, a chi TUI, `chi send`):
28
+ from a delegating parent, a schedule or a plugin it is refused and nothing runs, and a `/command` line from them is
29
+ plain text for the model. `chi answer`'s lines (a parent agent's `/continue yes`, `/parent-answers`) still run as
30
+ commands, with the same trust as `chi send`.
31
+ - A delegate's task and a chain schedule's firing now start their session as their sender's turn, not yours: its
32
+ `last_turn` origin reads `delegate` or `schedule`, the turn counts as automatic in the session's metrics, and the
33
+ objectives audit skips it as it skips a delegate's follow-up; it no longer counts as your input either (it doesn't
34
+ unarchive the session or reset its wakes). Restart `chi web` (and any worker) after updating: one
35
+ still running the older chi starts chain links whose first message runs as yours.
36
+ - `delegate`, `schedule_task` and `chi schedule add` refuse a task or prompt that starts with `!`, saying why, so the
37
+ model can write it as an instruction. A schedule stored with one before this fails when it fires (counted toward
38
+ its `max_failures`; nothing is started or delivered).
39
+ - A command you allow "for this session" is allowed again only in the same worktree or folder, with the same `env:`
40
+ (kept as a digest); the same text run in another checkout asks again. Session approvals stored before this ask one
41
+ more time.
42
+ - `chi bundle upgrade` and `chi update` report a replaced guardrail rule as Updated, like other bundle assets.
43
+ - Bundle install, upgrade and dry runs now name the guardrail, plugin and script files a new version drops.
44
+ - `chi bundle upgrade --dry-run` names edited files it would copy to the trash, including marked memories.
45
+ - `chi bundle uninstall` names a plugin it removes even when the bundle has no memories or hooks.
46
+ - `chi bundle status` labels retired hooks as present or missing; they are not loaded.
47
+ - `chi update` plan rows name memory files a shipped bundle adds.
48
+
11
49
  ## [0.159.0] - 2026-11-09
12
50
 
13
51
  Update with `chi update`.
@@ -5884,7 +5922,8 @@ and long-lived sessions.
5884
5922
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5885
5923
  hotkey panel that send selected text or the clipboard to your sessions.
5886
5924
 
5887
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.159.0...HEAD
5925
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.160.0...HEAD
5926
+ [0.160.0]: https://github.com/dm1try/samagotchi/compare/v0.159.0...v0.160.0
5888
5927
  [0.159.0]: https://github.com/dm1try/samagotchi/compare/v0.158.0...v0.159.0
5889
5928
  [0.158.0]: https://github.com/dm1try/samagotchi/compare/v0.157.0...v0.158.0
5890
5929
  [0.157.0]: https://github.com/dm1try/samagotchi/compare/v0.156.0...v0.157.0
data/docs/architecture.md CHANGED
@@ -105,7 +105,7 @@ terminal renders the events it gets over the Bridge.
105
105
  | Adapters | `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | Raw-prompt servers, the OpenAI chat API, Anthropic's Messages API, and the HTTP they share. |
106
106
  | Tools | `lib/samagotchi/tools/*` | Execute, read, edit, write, memory, task_*, web_fetch, delegate/delegate_result, ask_user_question, reminders, send_note, list_sessions, context_read, objectives, forget_outputs, find_tools/call_tool (the tool index), plus runtime/output-guardrails. |
107
107
  | Bundles | `lib/samagotchi/memory_bundle/*`, `BundleCommand` | `chi bundle`: install/upgrade (3-way, with a text merge of edited memories), build, uninstall; the install record under `.bundles/<name>/` that the guardrail and plugin loaders read ([internals/bundles.md](internals/bundles.md)). |
108
- | Background | `Samagotchi::Worker`, `Samagotchi::SessionManager` | `SessionManager` (`session_manager.rb`, its parts in `session_manager/`: spawn · worker_command · listing · delivery · lifecycle · removal · emptiness) forks/spawns one worker process per session. The worker process's main (owner lock, detach, signals, project env, what it does as it leaves) is `WorkerBoot` (`worker_boot.rb`), which the boot string loads directly; SessionManager's `run_session_loop` forwarder stays for older spawners' strings. Its `Worker` builds the `Engine` and `Bridge` (no terminal rendering) and runs queued turns. The spawned process (own process group) forks the worker, which `setsid`s and is reparented to init once its starter exits (it waits until the worker holds `owner.lock`, so the spawner's `Process.detach` waiter tells a starting worker): nothing that walks the spawner's process tree reaches it (`SAMAGOTCHI_WORKER_DETACH=1`, `detach_from_spawner`). The lock's pid is the worker's; `spawn`'s `child_pid` is its starter's. |
108
+ | Background | `Samagotchi::Worker`, `Samagotchi::SessionManager` | `SessionManager` (`session_manager.rb`, its parts in `session_manager/`: spawn · worker_command · listing · delivery · lifecycle · removal · emptiness) forks/spawns one worker process per session. The worker process's main (owner lock, detach, signals, project env, what it does as it leaves) is `WorkerBoot` (`worker_boot.rb`), which the boot string loads directly. Its `Worker` builds the `Engine` and `Bridge` (no terminal rendering) and runs queued turns. The spawned process (own process group) forks the worker, which `setsid`s and is reparented to init once its starter exits (it waits until the worker holds `owner.lock`, so the spawner's `Process.detach` waiter tells a starting worker): nothing that walks the spawner's process tree reaches it (`SAMAGOTCHI_WORKER_DETACH=1`, `detach_from_spawner`). The lock's pid is the worker's; `spawn`'s `child_pid` is its starter's. |
109
109
  | Web | `Samagotchi::Web::App`, `Samagotchi::Web::Server`, `Samagotchi::Web::SessionHub` | Rack+WEBrick on one port, `127.0.0.1:4567` (index.html + `/api/*` + SSE); with `web.host: lan` also on this machine's LAN address, behind an access token (`Web::Lan`, `Web::Token`). The hub is chi web's projection of the session list, pushed to every tab over `GET /api/events`. |
110
110
  | Sessions | `Samagotchi::Session`, `SessionManager` | File `sessions/<uuid>.json` + sidecar `input/`/`output/`; retention 14d/500, `updated_at desc`, lazy sweep. |
111
111
 
@@ -162,9 +162,8 @@ Unchanged: `ContextProviders`' `{ruby}` (`RbConfig.ruby`: the home's ruby inside
162
162
  project env below, so a `mise env` that sets GEM_HOME reaches it), `chi bundle
163
163
  try/test/check` (`-I LIB_DIR`), hooks (in-process). The worker's boot string, `run_session_loop(id, state_dir:)` and
164
164
  `SAMAGOTCHI_BOOT_FALLBACK` stay the cross-version contract (`spec/worker_contract_spec.rb`): the home only adds the
165
- prelude in front. The string names `WorkerBoot.run_session_loop`, the worker's main; the SessionManager forwarder
166
- remains one more release for boot strings written by older spawners. An older chi that wakes a session doesn't read
167
- home.json and starts its worker as before.
165
+ prelude in front. The string names `WorkerBoot.run_session_loop`, the worker's main. An older chi that wakes a
166
+ session doesn't read home.json and starts its worker as before.
168
167
 
169
168
  **The tools a worker runs** follow the project, not the boot, and only they do: a spawned worker
170
169
  (`WORKER_PROCESS_ENV`; never an in-process loop) calls `ProjectEnv.compute` in `WorkerBoot.run_session_loop`, after the owner
@@ -471,7 +470,7 @@ model: [internals/remote-sessions.md](internals/remote-sessions.md).
471
470
  | `DELETE /api/sessions/:id/context/:name?own=1` | owner | | 200 `{status: detached}`: the session's own only | 409 `project_source` (chi context rm's words); 404 |
472
471
  | `POST /api/sessions/:id/stop` | owner | `wait?` (seconds, capped at `SessionManager::STOP_WAIT`, 10: `chi --remote NAME sessions stop` sends it) | 200 `{status: stopped, session_id, released}`: `released`, whether the worker let go within the wait (without one `STOP_WAIT_SECONDS`, 2 s: the page's Stop) | 409 `owned_by_tui` |
473
472
  | `DELETE /api/sessions/:id?force=` | owner | `force=true` stops a live worker first (the page sends it) | as before | 409 `worker_running` without it |
474
- | `GET /api/sessions/:id?brief=1` | participant (theirs: title, status, participants) | | 200 `{brief, session: {id, status (the file's), title, display_title, project_root, working_directory, model_name, model_typed, parent_answers, owner}, pending_question}` (the file's; no messages): `Remote#show` | 404 |
473
+ | `GET /api/sessions/:id?brief=1` | participant (theirs: title, status, participants) | | 200 `{brief, session: {id, status (the file's), title, display_title, project_root, project_id, working_directory, model_name, model_typed, parent_answers, owner}, pending_question}` (the file's; no messages): `Remote#show` | 404 |
475
474
  | `POST /api/sessions/:id/turn` (its 202) | owner | | the ack with `via`: `bridge` or `file` (the input file: a remote `chi send --wait` follows that turn) | |
476
475
  | `GET /api/sessions/:id/context?full=1`, `…/context/:name?full=1` | owner | | each row with `source` (`Source#to_h`, its command too; the page's rows never carry one), `snapshot` (`Snapshot#to_h`, no text) and `shadowed`, shadowed project sources included | |
477
476
  | `POST /api/sessions/:id/wake` | owner | `wait` (seconds, default and most 10: `WAKE_MAX_WAIT`) | 200 `{session_id, woke, worker: {epoch, started_at, version, features}, model_name, default_model, parent_strategy, installed}`: the live worker as is (`woke: false`), else resumed and its Bridge waited for (`Local#wake`); `default_model` and `installed` are this machine's | 409 `owned_by_tui`, `scratch` (`Session::SCRATCH_REFUSED`); 504 `worker_timeout` (`LaunchMode::BRIDGE_LATE`); 400 `invalid_wait`; 404 |
@@ -655,7 +654,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
655
654
  | Dialects | `llm/dialect.rb` (`LLM::Dialect`) | What an `api: anthropic` provider's `/v1/messages` does differently (`dialect: fireworks`) ([internals/anthropic.md](internals/anthropic.md)). |
656
655
  | Logging | `log.rb` (`Log`), `log_subscriber.rb` (`LogSubscriber`), `log_path.rb` (`LogPath`), `log_line.rb` | The debug log: tagged records, a session's event trail, where the file goes ([configuration.md](configuration.md#debug-log-file)). |
657
656
  | Stores | `reminder_store.rb` (`ReminderStore`), `recap_store.rb` (`RecapStore`) | A session's reminders, and its idle recap (`recap.json`). |
658
- | Schedules | `schedules.rb` (`Schedules`, its `Entry`, `Tick`, `Index`), `schedule_command.rb` (`ScheduleCommand`) | Durable schedules that outlive every worker: `<state dir>/schedules.json` and its `schedules.lock`, the tick's busy check (a live worker asked over the Bridge's `GET /activity`, the files when there is none), the chain prune. `chi schedule` is this machine only. See [Scheduling](scheduling.md). |
657
+ | Schedules | `schedules.rb` (`Schedules`, its `Entry`, `Tick`, `Index`), `schedule_command.rb` (`ScheduleCommand`) | Durable schedules that outlive every worker: versioned `<state dir>/schedules.json` and its `schedules.lock`; rewrites preserve unknown keys and entries missing required members. The tick's busy check (a live worker asked over the Bridge's `GET /activity`, the files when there is none), the chain prune. `chi schedule` is this machine only. See [Scheduling](scheduling.md). |
659
658
  | Reply waits | `reply_wait.rb` (`ReplyWait`, `GONE_AFTER`: the one dead-worker rule), `reply_wait/poll.rb` (`ReplyWait::Poll`, one look per poll), `reply_wait/target.rb` (`ReplyWait::Target`: which turn a wait binds, cursor, baseline, `owner_grace`; `first_message`, `message`, `wait_only`, `answer`, `participant`, `from_cursor`), `reply_wait/liveness.rb` (`ReplyWait::Liveness`: one owner and link read per look; `#owner?` for gone, `#worker_owned?` for an open question) | `chi send --wait`, `chi answer`, a participant's wait, the delegate tools and delegate reports wait through `ReplyWait.call(id, **target.to_h, …)` ([sub-agent.md](sub-agent.md)). |
660
659
  | Session rows | `session_row.rb` (`SessionRow`) | A session as the lists see it: scalar facts only, built by `SessionManager.session_rows` (one owner, link, recap and analytics read each), `#to_summary_h` the summaries route's body and `.from_summary_h` a remote's rows read back, `#cli_line` the one `chi sessions list` line, `.running_or_starting?` the delegates' and Verify fresh's "running" (a 15 s grace for a worker on its way). A leaf: never session_manager or web. |
661
660
  | Setup and update | `bootstrap_command.rb`, `bootstrap/`, `update_command.rb`, `gem_update.rb` | `chi bootstrap` (first config.yml, system bundle, core profile) and `chi update` (the gem, shipped bundles, the macOS helper). |
data/docs/cli.md CHANGED
@@ -36,7 +36,7 @@
36
36
  - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`), and `big memories` the memories over `memory.entry_warn_tokens` ([Memory](memory.md#a-memorys-size)), and `model notes` the model notes a prompt of that model loads (`model_notes_deepseek (system, 612 chars)`, `none`; inside a session its `--mute` list is left out). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
37
37
  - `chi update [--dry-run] [--to VERSION] [--json] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, and check the bundles from elsewhere, in one table (see [Updating](#updating))
38
38
  - `chi home`, `chi home set [--ruby PATH]`, `chi home doctor` — the chi home: the one Ruby (and its gem path) every chi launch boots, whoever launches it. `chi home` shows it (its Ruby, the newest chi in it, gem home and path, `mise`, when and by which chi it was set, and the chi you ran when that is another one; exit 1 with the problem when it can't boot chi), `set` records this chi's Ruby and gem path (or `--ruby PATH`'s own; it must have samagotchi), `doctor` checks it and what should follow it, one row each with `ok`, `warn` or `fail` and what to run (exit 1 on a `fail`; see [Checking it](configuration.md#checking-it-chi-home-doctor)). At a terminal, a chi on another Ruby than the home's says so in one dim line a day. This machine only (see [The chi home](configuration.md#the-chi-home))
39
- - `chi project`, `chi project init [DIR]` — this folder's project, and making a folder in no git repository one. `chi project` prints how its project was found (`kind` `git`, `marker` or `none`), its `root` (what memories are keyed on), its `top` (where the AGENTS.md chain starts) and its project `memories` folder, with a `note` for a marker a repository makes ignored, or a `$HOME` that is a git repository. `init` writes `DIR/.chi/project.yml` (DIR defaults to this folder and is created when missing), so DIR and every subfolder share one memories folder, group their sessions and take `chi context --project`; it writes nothing in a git repository (the repository already is the project, exit 0), says so for a folder already marked, refuses `$HOME`, a folder above it and `/` (exit 1), and marks a folder inside another marked project as its own, nested one. It prints the root and the memories folder (with the memories already there: a folder chi ran in at its top keeps them), the running sessions in DIR to restart (`chi sessions restart ID`: their prompt keeps the old project) and, when DIR has saved sessions, that a running chi web groups them after its restart. This machine only. See [Projects without git](memory.md#projects-without-git)
39
+ - `chi project`, `chi project init [--new-id] [DIR]` — this folder's project, and making a folder in no git repository one. `chi project` prints how its project was found (`kind` `git`, `marker` or `none`), its `root`, its `top` (where the AGENTS.md chain starts), a marked project's `id` (what its memories and project context are keyed on; else the root's path is) and its project `memories` folder, with a `note` for a marker a repository makes ignored, or a `$HOME` that is a git repository. `init` writes `DIR/.chi/project.yml` with a new `id:` (DIR defaults to this folder and is created when missing), so DIR and every subfolder share one memories folder (kept when DIR moves or is renamed), group their sessions and take `chi context --project`; it writes nothing in a git repository (the repository already is the project, exit 0), says so (with the id) for a folder already marked, refuses `$HOME`, a folder above it and `/` (exit 1), and marks a folder inside another marked project as its own, nested one. On a folder marked without an id (an older chi's marker) it adopts one: the folders keyed by its path move to the id, each leaving a link by its old name, and it says what moved and names memories an earlier path left (refused, exit 1, while a session of the project runs). `--new-id` gives a marked folder a fresh id with a copy of its memories and project context (a copied folder going its own way; refused elsewhere, exit 1). It prints the root, the id and the memories folder (with the memories already there: a folder chi ran in at its top keeps them, moved to the id), the running sessions in DIR to restart (`chi sessions restart ID`: their prompt keeps the old project) and, when DIR has saved sessions, that a running chi web groups them after its restart. This machine only. See [Projects without git](memory.md#projects-without-git)
40
40
  - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Plugins](plugins.md) and [Bundle hooks](hooks.md#bundle-hooks)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `upgrade <name>` upgrades an installed bundle by name from the source it came from (its folder, archive or git URL), `upgrade <source>` from a new one; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). `install`/`upgrade` `--force` overwrites existing entries and also installs a bundle whose `requires_chi` this chi doesn't meet; without it such a bundle is refused (exit 1), nothing written. A plain `install` of a bundle that is already installed from the same source rewrites its rules, plugin, scripts and record: the summary lists a file that already holds the bundle's bytes as `Up to date: …`, one you wrote as `Skipped: …`, and ends with `NAME was already installed from SOURCE: its rules, plugin and scripts now match it and its record was rewritten` (plus, when a memory was kept, `your own copies of its memories were kept (chi bundle upgrade NAME merges the bundle's changes into them)`). A source with class-file hooks (`hooks/*.rb` or a manifest `hooks:` entry) is refused, `--dry-run` too; an older install's hooks are removed by the next install or upgrade (`Removed (chi no longer loads class-file bundle hooks): …`, an edited one copied to the trash first; see [Class-file hooks are retired](hooks.md#class-file-hooks-are-retired)). `status NAME` lists the bundle's guardrail rules and scripts beside its plugin, each `[ok]`, `[missing]` or `[modified]` (edited: the next install replaces it, its copy kept in the trash; an edited rule doesn't load, so it counts in `issues=`), and `diff NAME` names an edited rule or script with its recorded and current sha256 (chi keeps no base text for them). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
41
41
 
42
42
  - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
data/docs/guardrails.md CHANGED
@@ -92,10 +92,13 @@ Allowing beyond "once" is stored in `$XDG_STATE_HOME/samagotchi/guardrails/appro
92
92
 
93
93
  | Scope | Allows |
94
94
  |---|---|
95
- | session | this exact call in this session: the tool + command, or for a file tool its paths, so its label names them (`Allow edits to config.yml for the session`) and a later edit of that file needs no ask |
95
+ | session | this exact call in this session: the tool + command, or for a file tool its paths, so its label names them (`Allow edits to config.yml for the session`) and a later edit of that file needs no ask. A command's holds only where it was given: the same worktree or folder (`repo_root`, never the repository's other checkouts) and the same `env:` (a sha256 of it is stored as `env`, never the values) |
96
96
  | repo | this exact call in this repo (the cwd outside a repo), any session |
97
97
  | rule | anything this rule asks about in this repo |
98
98
 
99
+ A session entry of a command from before chi kept its place (no `repo_root`) no longer matches: the call asks once
100
+ more. `/guardrails` shows a session entry with its folder (`session 3fa2c1d0 in /work/app`, `with its env:`).
101
+
99
102
  "This repo" is the git repository, not the folder: an approval given in one
100
103
  worktree holds in the main checkout and every other worktree of it (it is
101
104
  stored with the repository's common git dir as `repo`, and the worktree as
@@ -294,6 +297,21 @@ by editing the session file, or by running `chi --attach` on a PTY with the
294
297
  markers cleared. chi's instructions for parents ([chi as a
295
298
  sub-agent](sub-agent.md)) tell them to use `chi answer` only.
296
299
 
300
+ ## Who may run a session command
301
+
302
+ A message that is a session command (`/model x`, `!cmd`, `!rollback`) runs as the command only when a person sends
303
+ it: a web tab, an attached chi TUI, `chi send` (yours, or one a model runs from `execute`: the trust model above), a
304
+ message with no sender named, and `chi answer`'s lines (`cli:answer`: a parent agent's `/continue yes` and
305
+ `/parent-answers`), which may send any session command, with the same trust as `chi send`.
306
+ `!cmd` runs its shell line with no rule or ask (it is yours), so from anyone else it is refused: a delegating parent's
307
+ task or follow-up, a schedule's firing (both modes), a plugin's message, a context or task wake. Chi web's Bridge answers
308
+ `403 command_not_permitted` with one message, an input file's line shows as the command's error, and nothing runs.
309
+ Their `/word` line is plain text: the model reads it. `delegate`, `schedule_task` and `chi schedule add` refuse a task
310
+ or prompt that starts with `!` with the same message, so the model can rewrite it. A participant's command is refused
311
+ as before ([their review boundary](#a-participants-review-boundary)). The rule is `CommandIntake`
312
+ (`lib/samagotchi/command_intake.rb`), asked by `Bridge#handle_post_turn` and `WorkerInbound#queue_as_command`;
313
+ `POST /command` (a UI's or a card's explicit command line) is not a message and is not covered.
314
+
297
315
  ## Protected paths
298
316
 
299
317
  Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
@@ -1508,6 +1526,7 @@ the text a rule sees, and a function or alias shadowing a reader in the same
1508
1526
  command isn't seen at all. `outside_repo` covers the file tools and git
1509
1527
  only: `sed -i`, `cp` or `cat > file` into another folder from the shell aren't
1510
1528
  asked about, and neither is git run through `sh -c`, a script, an alias, `make`
1511
- or a folder in a variable. `!cmd` lines typed by you are not checked. The only
1529
+ or a folder in a variable. `!cmd` lines typed by you are not checked (see
1530
+ [Who may run a session command](#who-may-run-a-session-command)). The only
1512
1531
  real defence against an adversarial model is isolation (a sandbox, a git
1513
1532
  identity without push rights).
@@ -51,8 +51,10 @@ comment is the file map and [../context.md](../context.md#storage) lists it for
51
51
  `Candidate`, `Left`, `Moved`/`Stayed`.
52
52
  - **Locations.** A `Location` is a folder of sources: a project's (`projects/<project_key>/`, shared by the
53
53
  project's workers) or a session's (`sessions/<id>/`, which also holds `subscriptions.json`, the `muted/` and
54
- `declined/` markers, `candidates/`, `left.json`). `ContextSources.attached(session_id, project_root:)` is what a
55
- session sees: its own, then the project's it doesn't shadow. Names are `NAME_RE`; `subscriptions` and `left` are
54
+ `declined/` markers, `candidates/`, `left.json`). `ContextSources.attached(session_id, project_root:, project_id:)`
55
+ is what a session sees: its own, then the project's it doesn't shadow. The project key is
56
+ `MemoryPaths.key_for(root:, id:)`, the memories folder's key: a marked project's id (`Session#project_id`, saved at
57
+ creation), else the root's path key; every caller holding a session passes both. Names are `NAME_RE`; `subscriptions` and `left` are
56
58
  reserved (`RESERVED_NAMES`) because their files share the folder.
57
59
  - **Writes are file replaces** (`AtomicFile`). `Location#locked(name)` takes `<name>.lock`, the lock a fetch holds
58
60
  for its whole command; `record_text` and `record_error` run under it, and `remove` leaves the lock file so a fetch
@@ -235,6 +235,13 @@ The Bridge exposes:
235
235
  sends its requests there (`worker_changed`); it gives up after ~12 s, or at once when the session
236
236
  was stopped.
237
237
 
238
+ **Adding an event type.** Assign every emitted type a lifetime in `Events::CLASSIFICATION`
239
+ (`row`, `state`, `transient` or `internal`); keep stream frames and synthetic replay types in
240
+ `Events::STREAM_FRAMES` and `Events::SYNTHETIC`. `spec/events_drift_spec.rb` checks event coverage
241
+ and names the live web/TUI handlers; for rows it also checks the reload owner (`TurnAccumulator`
242
+ or `CardStore`) and TUI join replay. Follow any documented exception there when a row reloads
243
+ through session messages or a fallback path instead.
244
+
238
245
  The wire chunk (the contract; the engine's own event keeps the provider's `payload` and the raw `content`):
239
246
 
240
247
  ```
@@ -538,7 +538,13 @@ Where a participant's author (`"p:<id>"`; `Steer.participant_author?`) goes (the
538
538
  - **`Session#last_turn.author`** and the analytics record's `author` (`SessionMetrics`); a Continue keeps the offer
539
539
  turn's author (`ContinueOffer#run_continue_turn`).
540
540
  - **Never a command**: a participant's `/word` or `!line` is refused (`Bridge#handle_post_turn` 403,
541
- `WorkerInbound#queue_as_command` drops it).
541
+ `WorkerInbound#queue_as_command` drops it). The same two intakes ask `CommandIntake.allowed?` for every other
542
+ sender: only a person's (`ClientId.human?`, not `review:`) and `cli:answer`'s line is a command; from a delegate, a
543
+ schedule, a plugin or a wake a `/word` line is a prompt and a `!` line is refused (the Bridge's 403
544
+ `command_not_permitted`; an input file's or a first prompt's is queued and `Worker#run_command_line` answers it with
545
+ the error `command_ran`). A session's first prompt carries its sender too: `WorkerInbound#initial_origin` reads
546
+ `Session#first_turn_client_id` (a delegate child without it: `ClientId.delegate` of its parent) and `first_turn_id`,
547
+ for `#initial_command?` and `Worker#run_initial_prompt`.
542
548
  - **Delivery**: a participant's input always waits for a turn of its own (`SessionInbox.waits_for_turn_end?`), so
543
549
  the drain never merges it; their turns count against chi web's `pending_limit` (429 `too_many_turns`).
544
550
  `Worker#run_prompt` checks their link and cap (`participant_refusal`) before the turn and after it.
data/docs/memory.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  Samagotchi stores memories in two scopes:
4
4
 
5
- - Project scope: `$XDG_CONFIG_HOME/samagotchi/memories/projects/<name>_<hash>/`, one folder per
6
- project (named and hashed by its root): a git repository, shared by all its worktrees and
7
- subdirectories, or a folder marked by `chi project init` and its subfolders; outside both, one
8
- per working directory
5
+ - Project scope: `$XDG_CONFIG_HOME/samagotchi/memories/projects/<key>/`, one folder per project: a
6
+ git repository, shared by all its worktrees and subdirectories (`<name>_<hash>`, named and hashed
7
+ by its root), or a folder marked by `chi project init` and its subfolders (its id,
8
+ `<name>-<8 hex>`: see [Projects without git](#projects-without-git)); outside both, one per
9
+ working directory (`<name>_<hash>`)
9
10
  - System scope: `$XDG_CONFIG_HOME/samagotchi/memories`
10
11
 
11
12
  `$XDG_CONFIG_HOME` falls back to `~/.config` when unset.
@@ -13,8 +14,9 @@ Samagotchi stores memories in two scopes:
13
14
  ## Projects without git
14
15
 
15
16
  A folder in no git repository becomes a project with `chi project init [DIR]` (see
16
- [cli.md](cli.md#commands)): it writes `DIR/.chi/project.yml` (one comment line; the file, not a bare
17
- `.chi/` dir, is the marker), and DIR and every subfolder share one project memories folder, group their
17
+ [cli.md](cli.md#commands)): it writes `DIR/.chi/project.yml` (a comment line and the project's
18
+ `id:`; the file, not a bare `.chi/` dir, is the marker), and DIR and every subfolder share one
19
+ project memories folder, group their
18
20
  sessions (`chi sessions list`, the web's project picker) and take `chi context --project`. Nothing
19
21
  chi-specific has to be committed anywhere: a repository never needs the file. It can also be created
20
22
  from chi web's project picker (a folder inside a git repository opens that repository's project, and
@@ -26,10 +28,34 @@ the folder is left as is: it is created when missing, nothing is marked).
26
28
  at `~` puts every folder under home in that repository, so markers there never apply.
27
29
  - **Not at home.** A marker in `$HOME`, in a folder above it, or in `/` is ignored (`chi project init`
28
30
  refuses them), so a marker in `~` never makes every folder under home one project.
29
- - **The key.** The marked root is the folder's real path (as git's root is), so a folder chi already ran
30
- in at its top keeps its memories after `init`, and a later `git init` in the same folder keeps the same
31
- key. Memories saved from a *subfolder* before `init` stay in that subfolder's own folder: move them by
32
- hand if you want them in the project's.
31
+ - **The key is the id.** A marked project's memories folder and its project context sources
32
+ (`chi context --project`) are named by the marker's `id:` (`<folder name>-<8 random hex>`), not
33
+ by its path, so moving or renaming the folder keeps them: a session started there after the move
34
+ finds them, and one saved before it (its file stores `project_id`) keys its context sources by the
35
+ id too. A folder chi already ran in at its top keeps its memories after `init`: they move to the
36
+ id. A later `git init` in the folder makes it a repository, keyed by its path again (git wins): its
37
+ memories stay in the id folder until you move them, and chi names the id folder at session start
38
+ (and under a `memory_read` miss) while the repository's own folder holds no memories. Memories saved from a *subfolder* before
39
+ `init` stay in that subfolder's own folder: move them by hand if you want them in the project's.
40
+ A hand-edited id must be letters, digits, `_`, `.` or `-` (up to 64, a letter or digit first);
41
+ anything else (or a broken file) is ignored and the project keyed by its path (a debug log line
42
+ says why, `chi project init` reports it).
43
+ - **Older markers adopt an id** when you rerun `chi project init` in the folder: the folders keyed
44
+ by its path (memories, project context sources, the skills bundle's kept versions) move to the id,
45
+ each leaving a link by its old name (an older chi, keying by path, finds the same folder), then the
46
+ id is written. It refuses while a session of the project runs (stop it first, so nothing writes to
47
+ the old folder mid-move), and names memories an earlier path left (the same folder name with
48
+ another hash: a folder moved before it had an id), merging nothing. A link stays behind: a new
49
+ folder (or git repository) made later at the old path reaches the moved project's memories
50
+ through it; delete the link to stop that. chi web's project picker marks a new folder with an id
51
+ but never moves folders: a folder it marks that already has path-keyed memories gets no id until
52
+ `chi project init`.
53
+ - **A copy shares memories.** A copied marked folder (`cp -R`) keeps the id, so the two share one
54
+ memories folder; at session start in the copy chi says so (a `memory>` load warning: "… share chi
55
+ project id … (a copied folder?)"; the id folder's `.root` names the folder that started a session
56
+ with it last, the original keeps it). `chi project init --new-id` in the copy gives it a fresh id,
57
+ with a copy of the memories and project context sources it had: each folder goes its own way.
58
+ After a move, the new path just takes `.root` over, with no warning.
33
59
  - **Restart what runs there.** A session running in the folder when it is marked keeps the old memories
34
60
  folder and AGENTS.md chain in its prompt until `chi sessions restart ID` (init names them); a running
35
61
  `chi web` groups the folder's older sessions under the project after its restart.
@@ -52,13 +78,15 @@ Tool behavior:
52
78
 
53
79
  ## Moving memories to another machine
54
80
 
55
- A project's folder is keyed by its root's absolute path (`<basename>_<8 hex of MD5(root)>`), so project
81
+ A repository's folder is keyed by its root's absolute path (`<basename>_<8 hex of MD5(root)>`), so project
56
82
  memories copied to a machine (or a path) where the repository lives elsewhere sit in a folder chi no
57
83
  longer reads. Rename the folder to the new key, or move memories with a bundle zip (see
58
- [What a bundle owns](#what-a-bundle-owns)).
84
+ [What a bundle owns](#what-a-bundle-owns)). A marked project with an id needs neither: copy its id
85
+ folder along with the marked folder.
59
86
 
60
87
  Inside a project (a git repository or a marked folder), when this project's folder is missing or holds no `*.md` while the projects
61
- dir has a folder of the same basename with another hash, chi says so, naming both folders and the
88
+ dir has a folder of the same basename with another hash (`<basename>_<hash>`, or a marked project's id folder
89
+ `<basename>-<8 hex>`; links, an adopted project's old names, are skipped), chi says so, naming both folders and the
62
90
  `mv` that fixes it (a name match can't prove it's the same project, so nothing is renamed for you):
63
91
 
64
92
  - at session start, a `memory>` load warning (a `:guardrail_warning` with label `memory`; the attached
data/docs/scheduling.md CHANGED
@@ -8,6 +8,11 @@ chi web's hub can never lose an update to each other). A **tick** — cron, syst
8
8
  launchd, `chi schedule tick`, or chi web's hub when `schedule.auto_tick` is on —
9
9
  walks the entries whose `next_due` has passed and runs the ones that are not busy.
10
10
 
11
+ The file is versioned: each entry member is required or declares the format
12
+ version and default that introduced it. Rewrites preserve unknown keys and
13
+ entries missing required members; those entries are warned about and ignored by
14
+ `list` and `tick`, never erased. A write never lowers the file's version.
15
+
11
16
  Each entry carries `id`, an optional `title`, `anchor` (the session it was made
12
17
  against, never rewritten: the chain's root and the id you remember), `head` (the
13
18
  chain's current link, re-anchored after every firing: the link a chain continue
@@ -30,9 +35,17 @@ depends on whether the worker has to be running.
30
35
  | Longest interval | 1440 minutes | unlimited (`every`, `weekday`, `date`) |
31
36
  | What it produces | a turn in the same session | a new chain link, or a queued turn in the same session |
32
37
 
33
- A schedule turn carries `client_id: "system:schedule:<id>"`, so `ClientId.origin`
34
- says `schedule` and the terminal and the web label it as such rather than as
35
- something you typed. Like `register_reminder`, the schedule tools are owner-voiced:
38
+ A schedule turn carries `client_id: "system:schedule:<id>"` in both modes (a chain
39
+ link's first turn through `Session#first_turn_client_id`, which `Schedules#fire_chain`
40
+ passes to `SessionContinue.run`), so `ClientId.origin` says `schedule` and the terminal
41
+ and the web label it as such rather than as something you typed. For the same reason a
42
+ schedule's prompt is never a session command: a `/word` prompt reaches the model as
43
+ text, and one that starts with `!` is refused by `chi schedule add` and `schedule_task`
44
+ ([guardrails.md, Who may run a session command](guardrails.md#who-may-run-a-session-command)).
45
+ An entry stored with one before that fails when it fires (toward its `max_failures`),
46
+ before anything is started or delivered. A chi web
47
+ (or cron tick) still running an older chi starts chain links without the origin, so
48
+ restart it after updating. Like `register_reminder`, the schedule tools are owner-voiced:
36
49
  a participant's review or pair turn is denied them ([guardrails.md](guardrails.md)).
37
50
 
38
51
  ## The two modes
data/docs/sessions.md CHANGED
@@ -115,7 +115,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
115
115
 
116
116
  ## Session file
117
117
 
118
- `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. So does `toolkit_origin`: an unknown one (a String) reads as `chosen` and is written back as it was, until the session sets its own. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
118
+ `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 43 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `first_turn_client_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. So does `toolkit_origin`: an unknown one (a String) reads as `chosen` and is written back as it was, until the session sets its own. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
119
119
 
120
120
  | Key | Meaning | Written by |
121
121
  |-----|---------|------------|
@@ -136,6 +136,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
136
136
  | `pending_question` | The question the worker waits on (`PendingQuestion`), `null` with none. | `QuestionDesk` |
137
137
  | `used_memory_names` | The memories the session read. | the Engine |
138
138
  | `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a repository or a marked folder (looked up again on read). | creation |
139
+ | `project_id` | A marked project's id (its `.chi/project.yml`'s `id:`), saved beside `project_root`: the session's project context sources are keyed by it, so they follow a moved folder. Absent for any other project. | creation |
139
140
  | `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt. | creation |
140
141
  | `prompt_notes` | The model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)). | the Engine, at each prompt build |
141
142
  | `parent_id` | The session that delegated this one or forked it (`ctx.sessions.fork`); `null` otherwise. See [Delegating](#delegating). | `SessionManager.spawn_session`; `ChildMove` writes a `parent.json` beside it instead of this file |
@@ -156,6 +157,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
156
157
  | `working_set_pins` | The working-set items the user pinned (`/working-set pin`); kept by a respawn, a continue and a fork. | the Engine's command |
157
158
  | `review` | A review session's subjects and source (`SessionReview`), see [Review sessions](#review-sessions). | `ReviewStart` |
158
159
  | `first_turn_id` | The id the creating prompt's turn gets, so a wait can name it (`chi send --new --wait`, `--wait --turn T ID`); `null` for a session created idle. | `SessionManager.spawn_session` |
160
+ | `first_turn_client_id` | Who sent the creating prompt when no person did (`delegate:<parent8>` for a delegate's task, `system:schedule:<id>` for a chain schedule's link): that first turn's origin, so its line is no command of the owner's (docs/guardrails.md, Who may run a session command) and `last_turn` names the sender. Written only when set; not carried by a fork, a continue or a delegate. An older chi keeps it but runs the first prompt as a person's. | `SessionManager.spawn_session` (its `client_id:`) |
159
161
  | `verify_fresh` | A Verify fresh child's requester, rules and claim (`VerifyFreshOrigin`). | `VerifyFresh.start`, once |
160
162
 
161
163
  ## Context notes
@@ -9,6 +9,7 @@ require "openssl"
9
9
  require "time"
10
10
  require_relative "atomic_file"
11
11
  require_relative "bridge_secret"
12
+ require_relative "command_intake"
12
13
  require_relative "degrade"
13
14
  require_relative "delivery"
14
15
  require_relative "session_inbox"
@@ -1195,9 +1196,17 @@ module Samagotchi
1195
1196
  # A session command sent as a message (chi send -m "/model x", chi -p,
1196
1197
  # a UI whose command list lags) runs as the command, as typed in a
1197
1198
  # TUI; an unknown /word stays a prompt. Not with images: those are
1198
- # for the model.
1199
+ # for the model. Only from a sender that may send commands
1200
+ # (CommandIntake.allowed?: a person, chi answer): a delegate's, a
1201
+ # schedule's or a plugin's /word line is a prompt, its shell line
1202
+ # refused (403, nothing queued).
1199
1203
  if images.empty? && @on_command && command_registry.command?(prompt.to_s)
1200
- return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
1204
+ if CommandIntake.allowed?(client_id)
1205
+ return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
1206
+ elsif CommandIntake.shell_line?(prompt)
1207
+ Log.warn(:bridge, "shell_line_refused", client_id: client_id)
1208
+ return [{}, 403, { error: CommandIntake::REFUSED_ERROR, detail: CommandIntake::REFUSED }]
1209
+ end
1201
1210
  end
1202
1211
 
1203
1212
  enqueued_id = SecureRandom.uuid
@@ -46,7 +46,8 @@ module Samagotchi
46
46
  session = Session.load(recipient.id, state_dir: state_dir)
47
47
  prompts = user_texts(session.messages)
48
48
  branch = GitHead.branch(session.working_directory)
49
- sources = ContextSources.attached(session.id, project_root: session.project_root, state_dir: state_dir)
49
+ sources = ContextSources.attached(session.id, project_root: session.project_root, project_id: session.project_id,
50
+ state_dir: state_dir)
50
51
  .map { |attached| [attached.name, attached.source.hint] }
51
52
  ScopeCard.new(id: session.id, project: recipient.project && File.basename(recipient.project),
52
53
  folder: folder(session.working_directory, recipient.project), branch: branch,
@@ -266,6 +266,7 @@ module Samagotchi
266
266
  # @return [Array(MemoryBundle::PreparedSource, String), nil]
267
267
  def prepare_or_fail(expanded_source, word)
268
268
  prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
269
+ Samagotchi::MemoryBundle::Manifest::HooksRetired.refuse_source_files(prepared.dir)
269
270
  named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
270
271
  rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
271
272
  Samagotchi::MemoryBundle::Manifest::ValidationError => e
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.159.0
3
+ version: 0.160.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:750822b3488b302c6e32638e6224f270d8e06b50ea85cd6e0bb5a6ff12eed1d8
9
9
  config_modification_protocol.md: sha256:44cf7bdac8e526805bff36b76b54fefecd41db30ca525c2ed661d78fe9155dd8
10
- memory_guide.md: sha256:91e8fd248a155afad6b825097d9ee0a0215ba0b79092c8ab25501c0fc19dedfc
10
+ memory_guide.md: sha256:bdd34f34063df1b4ae74f7cda7c31080986d155e535ee4526d673c70edac8292
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -13,7 +13,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
13
13
  | Scope | Path | Use for |
14
14
  |-------|------|---------|
15
15
  | `system` | `$XDG_CONFIG_HOME/samagotchi/memories/`, default `~/.config/samagotchi/memories/` (`MemoryPaths.system_dir`) | User-wide preferences, identity, cross-project knowledge |
16
- | `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
16
+ | `project` | `<system dir>/projects/<key>/` (`MemoryPaths.project_dir`: a git project's `<basename>_<hash>`, `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`; a marked project's id from its `.chi/project.yml`, `<basename>-<8 hex>`, kept when the folder moves) | Repo-specific conventions, workflow, stack decisions |
17
17
 
18
18
  - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows that folder (not the root: in a linked worktree, work stays in the worktree). A separate clone is a different project. Outside a repository, a folder marked by `chi project init` (its `.chi/project.yml`) is the project for it and every subfolder; outside both, the root is the working directory.
19
19
  - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
@@ -122,7 +122,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
122
122
  | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and an installed bundle's plugin, rules and scripts. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
123
123
 
124
124
  **Scope resolution for install/build:**
125
- - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`MemoryPaths.scope_dir`).
125
+ - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<key>` of the project (`MemoryPaths.scope_dir`).
126
126
 
127
127
  **Example flows:**
128
128
  ```bash
@@ -98,6 +98,10 @@ module Samagotchi
98
98
  # Whether +client_id+ is a participant's (review:).
99
99
  def review?(client_id) = client_id.to_s.start_with?(REVIEW_PREFIX)
100
100
 
101
+ # The client id a delegating parent's task and follow-ups carry:
102
+ # delegate:<parent8>.
103
+ def delegate(parent_id) = "#{DELEGATE_PREFIX}#{parent_id.to_s[0, 8]}"
104
+
101
105
  # The client id a durable schedule's turn carries: system:schedule:<id>.
102
106
  def schedule(schedule_id) = "#{SCHEDULE_PREFIX}#{schedule_id}"
103
107
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client_id"
4
+
5
+ module Samagotchi
6
+ # Who may send a session command as a message (a turn's text: chi send -m
7
+ # "/model x", a web page's first message, an input file, a session's
8
+ # first prompt), and what a line from anyone else is. The one rule the
9
+ # two intakes ask (WorkerInbound#queue_as_command, Bridge#handle_post_turn)
10
+ # and the senders that refuse up front (Tools::Delegate, Schedules#add).
11
+ # No Engine needed: a schedule's tick and the CLI have none.
12
+ #
13
+ # From a sender that may not, a /word line is plain prompt text (the
14
+ # model reads it) and a ! line (a shell command: SessionCommands#shell
15
+ # runs it with no Gate) is refused with REFUSED, never run.
16
+ module CommandIntake
17
+ # A shell line's prefix: !<cmd>, and !rollback (SessionCommands::
18
+ # SHELL_BANG_PREFIX, which a spec keeps the same: SessionCommands is too
19
+ # big a load for the bundle test kit's budget).
20
+ SHELL_PREFIX = "!"
21
+
22
+ # The one text a refused shell line gets: the Bridge's 403 detail, the
23
+ # worker's command_ran error, a delegate's or a schedule's refusal.
24
+ REFUSED = "a message that starts with ! is a shell command, which only a person runs as a message " \
25
+ "(a web tab, a chi TUI, chi send); from a delegate, a schedule or another automatic sender it is not run. " \
26
+ "Say what to run in words instead (\"run `ls`\"), or leave out the leading !"
27
+
28
+ # The Bridge's error code for it (403).
29
+ REFUSED_ERROR = "command_not_permitted"
30
+
31
+ # chi answer's marker (a parent agent's /continue yes and
32
+ # /parent-answers; Worker#as_parent? holds those to its own rules): any
33
+ # command, with chi send's trust.
34
+ ALLOWED_IDS = [ClientId::CLI_ANSWER].freeze
35
+
36
+ module_function
37
+
38
+ # Whether a message from +client_id+ may run as a session command: a
39
+ # human's input (ClientId.human?: a web tab, a chi TUI, chi send, no
40
+ # client) but a participant's (review:, refused on its own), and chi
41
+ # answer's. A delegate, a child's report, a context or task wake, a
42
+ # schedule, a reminder, a relay and a plugin may not (the bundles that
43
+ # send, coordinator and skills, send prose).
44
+ def allowed?(client_id)
45
+ ALLOWED_IDS.include?(client_id.to_s) || (ClientId.human?(client_id) && !ClientId.review?(client_id))
46
+ end
47
+
48
+ # Whether +text+ would be a shell line (!<cmd>, !rollback) if a command:
49
+ # what a delegate's task or a schedule's prompt may not start with.
50
+ def shell_line?(text) = text.to_s.lstrip.start_with?(SHELL_PREFIX)
51
+
52
+ # Whether the command line +line+ from +client_id+ is a shell line that
53
+ # must not run.
54
+ def refused?(line, client_id) = shell_line?(line) && !allowed?(client_id)
55
+ end
56
+ end
@@ -59,17 +59,19 @@ module Samagotchi
59
59
  VERDICT = /\bVERDICT:?\s*([A-Z]+)(?![A-Za-z|])/
60
60
 
61
61
  # @param project_root [String, nil] Session#project_root
62
+ # @param project_id [String, nil] Session#project_id
62
63
  # @param peer_sources [PeerSources, nil] the peer bundles' declared sources
63
64
  # @param peer_turns [#call, nil] the peer wake turns in a row
64
65
  # (WorkerWakes#peer_turns); 0 or nil: no source is a peer's
65
- def initialize(session_id:, state_dir:, project_root:, peer_sources: nil, peer_turns: nil)
66
+ def initialize(session_id:, state_dir:, project_root:, project_id: nil, peer_sources: nil, peer_turns: nil)
66
67
  @session_id = session_id
67
68
  @peer_sources = peer_sources
68
69
  @peer_turns = peer_turns
69
70
  @state_dir = state_dir
70
71
  @own = ContextSources.session_location(session_id, state_dir: state_dir)
71
- @project = ContextSources.project_location_for(project_root, state_dir: state_dir)
72
+ @project = ContextSources.project_location_for(project_root, project_id: project_id, state_dir: state_dir)
72
73
  @project_root = project_root
74
+ @project_id = project_id
73
75
  @signature = nil
74
76
  end
75
77
 
@@ -79,7 +81,8 @@ module Samagotchi
79
81
  return nil if signature == @signature
80
82
 
81
83
  subs = @own.subscriptions
82
- visible = ContextSources.attached(@session_id, project_root: @project_root, state_dir: @state_dir)
84
+ visible = ContextSources.attached(@session_id, project_root: @project_root, project_id: @project_id,
85
+ state_dir: @state_dir)
83
86
  deliveries = visible.filter_map do |attached|
84
87
  next if @own.muted?(attached.name)
85
88