samagotchi 0.158.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -7
  5. data/docs/cli.md +57 -2
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +24 -3
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/attached-context.md +4 -2
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +9 -7
  13. data/docs/internals/engine.md +7 -1
  14. data/docs/memory.md +46 -14
  15. data/docs/scheduling.md +241 -0
  16. data/docs/sessions.md +28 -2
  17. data/lib/samagotchi/bridge.rb +37 -4
  18. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  19. data/lib/samagotchi/bundle_command.rb +39 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  22. data/lib/samagotchi/client_id.rb +17 -3
  23. data/lib/samagotchi/command_intake.rb +56 -0
  24. data/lib/samagotchi/config.rb +12 -0
  25. data/lib/samagotchi/context_absorber.rb +6 -3
  26. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  27. data/lib/samagotchi/context_candidates.rb +11 -8
  28. data/lib/samagotchi/context_command.rb +9 -3
  29. data/lib/samagotchi/context_move.rb +74 -0
  30. data/lib/samagotchi/context_poller.rb +5 -2
  31. data/lib/samagotchi/context_sources.rb +6 -4
  32. data/lib/samagotchi/engine/events.rb +13 -2
  33. data/lib/samagotchi/engine/guardrails.rb +3 -4
  34. data/lib/samagotchi/engine.rb +1 -0
  35. data/lib/samagotchi/engine_peers.rb +1 -0
  36. data/lib/samagotchi/events.rb +71 -0
  37. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  38. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  39. data/lib/samagotchi/hook_routes.rb +0 -1
  40. data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
  41. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  42. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  43. data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  46. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  47. data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
  48. data/lib/samagotchi/memory_bundle/status.rb +22 -3
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
  50. data/lib/samagotchi/memory_orphans.rb +14 -4
  51. data/lib/samagotchi/memory_paths.rb +29 -6
  52. data/lib/samagotchi/memory_usage.rb +23 -12
  53. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  54. data/lib/samagotchi/plugin/sessions.rb +4 -1
  55. data/lib/samagotchi/project_command.rb +58 -20
  56. data/lib/samagotchi/project_id.rb +102 -0
  57. data/lib/samagotchi/project_id_clash.rb +62 -0
  58. data/lib/samagotchi/project_init.rb +214 -10
  59. data/lib/samagotchi/project_scope.rb +11 -2
  60. data/lib/samagotchi/run_options.rb +1 -0
  61. data/lib/samagotchi/schedule_command.rb +252 -0
  62. data/lib/samagotchi/schedules.rb +1008 -0
  63. data/lib/samagotchi/session.rb +17 -2
  64. data/lib/samagotchi/session_access/contract.rb +1 -1
  65. data/lib/samagotchi/session_access/local.rb +28 -5
  66. data/lib/samagotchi/session_access/remote.rb +2 -1
  67. data/lib/samagotchi/session_access.rb +4 -1
  68. data/lib/samagotchi/session_commands.rb +7 -3
  69. data/lib/samagotchi/session_continue.rb +10 -6
  70. data/lib/samagotchi/session_manager/spawn.rb +34 -13
  71. data/lib/samagotchi/session_manager/worker_command.rb +11 -23
  72. data/lib/samagotchi/state_format.rb +118 -0
  73. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  74. data/lib/samagotchi/tool_declarations.rb +43 -0
  75. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  76. data/lib/samagotchi/tools/builtins.rb +17 -0
  77. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  78. data/lib/samagotchi/tools/context_read.rb +7 -2
  79. data/lib/samagotchi/tools/delegate.rb +10 -2
  80. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  81. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  82. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  83. data/lib/samagotchi/update_command.rb +2 -2
  84. data/lib/samagotchi/version.rb +1 -1
  85. data/lib/samagotchi/web/app.rb +5 -4
  86. data/lib/samagotchi/web/public/chains.js +32 -8
  87. data/lib/samagotchi/web/public/index.html +4 -1
  88. data/lib/samagotchi/web/public/info_bar.js +4 -3
  89. data/lib/samagotchi/web/public/turn_events.js +4 -0
  90. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  91. data/lib/samagotchi/web/session_hub.rb +20 -0
  92. data/lib/samagotchi/worker.rb +42 -12
  93. data/lib/samagotchi/worker_boot.rb +3 -4
  94. data/lib/samagotchi/worker_inbound.rb +44 -2
  95. data/lib/samagotchi/worker_link.rb +5 -0
  96. data/lib/samagotchi/worker_wakes.rb +9 -1
  97. metadata +12 -1
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
@@ -591,7 +619,11 @@ memory still marked as behind an earlier upgrade keeps its mark either way.
591
619
  rule above: edited, or with nothing recorded to tell), `missing` or
592
620
  `unreadable`, and that a file stays behind and why. Its `issues=` counts files
593
621
  behind, missing or unreadable (and a plugin that won't load, or class-file hooks an older chi installed, which no
594
- longer load: `Hooks (N): not loaded`), not edits. A
622
+ longer load: `Hooks (N): not loaded`), not edits. `chi bundle status NAME`
623
+ lists the bundle's guardrail rules and scripts too (`Rules (N):`, `Scripts (N):`),
624
+ each `[ok]`, `[missing]` or `[modified]`: edited, so the next install or upgrade
625
+ replaces it, its copy kept in the trash. An edited or missing rule doesn't load
626
+ and counts in `issues=`, as a missing script does. A
595
627
  file an older chi marked, which no retry has looked at, says `may be behind`, and so
596
628
  do the counts in `chi bundle list`, `chi self` and `chi update`'s last line.
597
629
  `chi bundle status NAME` also has a `Manifest:` line: `[kept]` when chi reads
@@ -0,0 +1,241 @@
1
+ # Schedules (`chi schedule`)
2
+
3
+ A **schedule** is a prompt with a clock. It lives in one file on this machine, not in a
4
+ worker's memory, so it outlives every session, every restart and every crash:
5
+ `~/.local/state/samagotchi/schedules.json` (next to `sessions/`, written with
6
+ `AtomicFile`, every change a read-modify-write under `schedules.lock`, so cron and
7
+ chi web's hub can never lose an update to each other). A **tick** — cron, systemd,
8
+ launchd, `chi schedule tick`, or chi web's hub when `schedule.auto_tick` is on —
9
+ walks the entries whose `next_due` has passed and runs the ones that are not busy.
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
+
16
+ Each entry carries `id`, an optional `title`, `anchor` (the session it was made
17
+ against, never rewritten: the chain's root and the id you remember), `head` (the
18
+ chain's current link, re-anchored after every firing: the link a chain continue
19
+ made, or the session a `same` turn was queued into), `mode`,
20
+ `prompt`, `recurrence`, `catch_up`, `keep_links`, `max_failures`, `max_skips`,
21
+ `next_due` (UTC ISO), `last_fired`, `last_result`, `failures`, `skips` and
22
+ `enabled`.
23
+
24
+ <a id="reminder-vs-schedule"></a>## Reminder or schedule?
25
+
26
+ `register_reminder` and `chi schedule` are separate on purpose; which one you want
27
+ depends on whether the worker has to be running.
28
+
29
+ | | `register_reminder` | `chi schedule` |
30
+ |---|---|---|
31
+ | Where it lives | the worker's `Engine` (`ReminderStore`), in memory | `<state dir>/schedules.json`, durable |
32
+ | Who fires it | `IdleReminders`, inside a live idle worker | `chi schedule tick`, run by cron/systemd (or the hub when opted in) |
33
+ | Needs a worker | yes — and a reminder *holds* the worker up | no — it wakes one when none runs |
34
+ | Survives a stop, a crash, an archive | only the short asked-restart hand-off (`ReminderCarry`, 2 min) | yes, until cancelled |
35
+ | Longest interval | 1440 minutes | unlimited (`every`, `weekday`, `date`) |
36
+ | What it produces | a turn in the same session | a new chain link, or a queued turn in the same session |
37
+
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:
49
+ a participant's review or pair turn is denied them ([guardrails.md](guardrails.md)).
50
+
51
+ ## The two modes
52
+
53
+ - **chain** (the default) runs the prompt as the **next link of the session's chain**:
54
+ the head is archived, a new link is created with the carry note
55
+ ([sessions.md, Session chains](sessions.md#session-chains)), and `head` moves to it.
56
+ This is the "same routine, fresh context" case: a daily coordinator that starts each
57
+ day with the recap of the last one instead of a 40-link transcript.
58
+ - **same** queues the prompt as a turn **in the session itself**
59
+ (`SessionManager.deliver_turn`, `Delivery::QUEUE`), waking a worker when none runs.
60
+ It prunes nothing, and it never archives anything. Like chain mode, it follows
61
+ `head` through the chain: once the session is continued — by a chain schedule or
62
+ by a `chi send --continues` — the turn goes to the new link, not to the archived
63
+ anchor the schedule was made against.
64
+
65
+ A `scratch` session is refused at `add`: it is deleted when its terminal leaves.
66
+ A prompt is checked at `add` too — non-empty and at most 16 KiB
67
+ (`SessionInbox::NOTE_MAX_BYTES`); a blank one would archive a link and start an
68
+ idle session for nothing.
69
+
70
+ ## Recurrence
71
+
72
+ One kind per schedule:
73
+
74
+ | Kind | Written as | How the next due is computed |
75
+ |---|---|---|
76
+ | `every` | `--every 45m` / `2h` / `1d` | interval arithmetic from the firing — `next_due = fired_at + interval` |
77
+ | `weekday` | `--weekday 1 --time 09:00` (1=Mon … 7=Sun) | the next such wall-clock moment in the machine's local zone, strictly after now (today counts if the time is still ahead), stored as UTC |
78
+ | `date` | `--date 2026-11-20` or `--date 2026-11-20T09:00` | one local moment; after it fires the entry is disabled |
79
+
80
+ **The DST caveat.** `every` is interval math in UTC, so an `every 1d` schedule
81
+ drifts by an hour across a DST fold: fired Monday 09:00, its next due is Tuesday
82
+ 09:00 UTC, which is a different hour on the wall clock after the change. For a
83
+ schedule at a *time of day*, use `--weekday` (or `--date`): those build the moment
84
+ with `Time.local` for the candidate date, so `Mon 09:00` stays 09:00 local across
85
+ the fold. If a local moment does not exist (a spring-forward gap), it fires at the
86
+ next valid minute. Schedules use the machine's local clock; there are no named
87
+ time zones.
88
+
89
+ `catch_up` says what an overdue entry does when the tick arrives after its moment:
90
+
91
+ - `once` (default): fire once now, then reschedule normally.
92
+ - `none`: fire only when the tick is near the moment. "Missed" — `last_result:
93
+ "missed"` and the entry moves to its next occurrence — is a **whole cycle**, not
94
+ a late tick: it happens only when the occurrence *after* the one it missed is
95
+ already past, which is what the file looks like when the machine was asleep
96
+ across the interval. A `Mon 09:00` schedule ticked at 09:20 fires; the same
97
+ entry first seen the following Monday is missed and moves to the Monday after.
98
+ A `date` one-off has no next occurrence to compare with, so it is never missed:
99
+ it fires late, once.
100
+
101
+ ## The busy check
102
+
103
+ A cron tick cannot read a worker's memory, so the tick has two paths.
104
+
105
+ **No live worker** (the usual case for a weekly schedule): the files say what can
106
+ be known — the saved status is not `running` (and not inside the starting grace),
107
+ `input/` is empty, no step-limit question is open. Nothing else can be busy.
108
+
109
+ **A live worker**: ask it, over the new Bridge route
110
+ `GET /session/:id/activity` (`Worker#activity_snapshot`, the same signals
111
+ `WorkerIdleExit#hold` reads): `{turn_running, input_pending, awaiting_continue,
112
+ open_streams, reminders, tasks_holding}`. It is in `Bridge::FEATURES`, so a worker
113
+ from an older chi has no such route, and any non-200 from it is read as "older":
114
+ the tick says to restart that worker.
115
+
116
+ - `turn_running`, `input_pending` or `awaiting_continue` ⇒ busy, in both modes.
117
+ - `open_streams` (a terminal attached, or a web tab open on the session) counts
118
+ **only for chain mode**: a chain firing archives the session and stops its
119
+ worker, which would cut off the person looking at it. A queued turn into an open
120
+ session is fine, so `same` mode ignores streams.
121
+ - **An older worker is itself a busy signal for chain mode.** Its 404 must not fall
122
+ back to the file checks: those cannot see an open tab, so the firing would
123
+ archive under someone's terminal. The tick skips it with "its worker is too old
124
+ to ask: `chi sessions restart ID`" — chi's usual rule for an older worker:
125
+ refuse with a restart, never silently degrade. `same` mode, which only queues a
126
+ turn, may use the file checks.
127
+
128
+ A `chi send --wait` or a parent's `delegate` wait is **not** busy: `ReplyWait::Poll`
129
+ reads the session files and `GET /session/:id/state`, never `open_stream`, so a
130
+ coordinator waiting on its own child does not block that child's chain.
131
+
132
+ Schedules never hold a worker up: unlike reminders, they are not in
133
+ `WorkerIdleExit#hold` (FR14) — the whole point is that the worker may be gone and
134
+ the tick wakes it.
135
+
136
+ ## Skip, not failure
137
+
138
+ A firing that cannot run now is a **skip**: nothing happens, `skips += 1`,
139
+ `last_result: "skipped"`, and `next_due` goes back to the occurrence this tick tried
140
+ (the tick advances the clock before acting and keeps that moment as `due_was`, so the
141
+ next tick tries it again — a weekly schedule skipped on its Monday is retried at that
142
+ Monday, not a week later). A failure restores it the same way. A `date` entry is the
143
+ exception: advancing it spends it, so it comes back at that same moment.
144
+ Skips
145
+ are the refusals that mean "not this moment": `ArchiveRefused` with `busy`, `queued`
146
+ or `offer`, or `busy_child`/`queued_child`/`offer_child` (a chain's own running
147
+ delegate refuses the archive, since `archivable!` walks the whole descendant tree),
148
+ `SessionContinue::Refused(:continued)` (a human continued it while we were
149
+ resolving) and `:open_children`.
150
+
151
+ Anything else is a **failure** (`failures += 1`): a gone or archived target,
152
+ `SessionContinue::Refused(:folder_gone)`, `SessionManager::DeleteRefused`,
153
+ `ModelProfile::MissingModel`, `OwnedByTUI`, `Session::NotFound`, and anything a
154
+ spawn throws (`Errno::ENOSPC` included — `run_locked` re-raises it after undoing
155
+ the move, so it is never mistaken for a skip). A `DeleteRefused(:worker_running)`
156
+ hit while pruning stops the pruning for that firing, is logged, and is not a
157
+ schedule failure.
158
+
159
+ `skips >= max_skips` (default 12) or `failures >= max_failures` (default 3)
160
+ disables the entry and `chi schedule list` says why. **Any successful firing resets
161
+ both counters**, so twelve skips spread over months do not kill a healthy weekly
162
+ schedule. `list` prints the skip budget as a time window — 12 skips at a 5 minute
163
+ tick is about an hour of patience, which is what the count really measures.
164
+
165
+ A chain holds at most `schedule.max_per_session` enabled schedules (default 10) and
166
+ the machine `schedule.max_total` (default 50); `schedule_task` and `chi schedule add`
167
+ refuse past them, naming the key. A schedule the chain already has — the same prompt
168
+ and recurrence, or the same title — is refused too, naming its id, so a routine that
169
+ tells each of its links to "make sure the Monday audit is scheduled" cannot multiply
170
+ itself. `schedule_task`, `list_schedules` and `cancel_schedule` see only schedules of
171
+ the calling session's chain (`all: true` steps outside it); `chi schedule list` is
172
+ always the machine-wide view. An id, prefix or title that matches more than one entry
173
+ is refused with exit 2, naming the matches.
174
+
175
+ The tick's lock covers the file only, never the action: the clock advances before
176
+ the work starts — for `every` and `weekday` to the next occurrence, for a `date`
177
+ one-off to none — so a tick killed mid-continue cannot fire the same entry twice.
178
+ `firing_since` is what `chi schedule show` prints; the advanced clock is the guard.
179
+ And a `chi schedule add` from a terminal never waits on an archive or a recap wait. Chain mode targets `last:<head>`, so `SessionContinue.run`'s
180
+ own retry loop follows a continue a human made between our resolve and its lock
181
+ instead of racing it.
182
+
183
+ ## Keeping the chain short
184
+
185
+ `keep_links` (default `schedule.default_keep_links`, `0` keeps all) is how many
186
+ chain links stay behind the head. After a successful chain firing — and after the
187
+ result is recorded under the lock, so a prune that raises midway cannot lose the
188
+ new `head` — the walk goes backwards from `head` and every link older than the
189
+ window is deleted **together with its delegated children** (`descendant_ids`): the
190
+ whole subtree only when every session in it is archived and no worker owns any of
191
+ it, deepest child first and the link last, oldest link first. A subtree with a live
192
+ worker or an unarchived descendant is left whole and retried at the next firing. Pruning a link alone would leave its finished
193
+ delegates archived forever with a dangling `parent_id`: archiving a session archives
194
+ its descendants, and archived sessions are outside the retention sweep
195
+ ([sessions.md](sessions.md)), so the delegate pile is the bulk of the archived
196
+ count. The `anchor` is never pruned: it is the chain's root, the id the user and
197
+ the coordinator remember, and `last:<anchor>` must keep resolving.
198
+
199
+ The oldest kept link still has `continues` naming a link that is gone. The walks
200
+ follow `continues` forward, so nothing breaks; the web's `↩ #N of M` chip counts
201
+ from the surviving root (pruning renumbers the links) and says "earlier links
202
+ pruned" instead of linking to a deleted session.
203
+
204
+ If a link to prune still has a worker (`DeleteRefused(:worker_running)`), pruning
205
+ stops there for this firing and is retried at the next one.
206
+
207
+ ## Running the tick
208
+
209
+ `chi schedule tick` is quiet and safe to run often; it is this machine only
210
+ (`chi --remote studio schedule list` exits 2), so point cron at the chi of this
211
+ machine (cron runs with a thin PATH, so name the binary rather than a shell
212
+ function):
213
+
214
+ ```
215
+ # crontab -e on the machine
216
+ */5 * * * * /Users/you/.local/share/mise/installs/ruby/global/bin/chi schedule tick >> /Users/you/.local/state/samagotchi/schedule-tick.log 2>&1
217
+ ```
218
+
219
+ Run it from systemd or launchd instead if you prefer a timer: the command is the
220
+ same, `chi schedule tick`, and the lock makes overlapping runs harmless — the
221
+ second one exits 0 with "no schedules due (another tick holds the lock)".
222
+ `--dry-run` prints what would fire and changes nothing; `--format json` is the seam
223
+ for scripts and for a future web UI.
224
+
225
+ A tick fires **one entry per chain**: of the due entries of one chain, the one due
226
+ earliest (`next_due`, then id) fires and the rest are skipped — "the chain just
227
+ fired; retry at the next tick" — with `skips += 1` and `next_due` left at the
228
+ occurrence they tried, so the chain's entries take turns instead of the first one
229
+ added winning every tick. A second chain
230
+ firing in the same pass would continue the link the first one just made and archive
231
+ it before its prompt ever ran: that link has no worker lock yet and the tick's
232
+ listing predates it, so the busy check cannot see it. `same` entries are unaffected:
233
+ they queue a turn, they do not archive.
234
+
235
+ `schedule.auto_tick: true` makes chi web's hub tick on
236
+ `schedule.tick_interval_minutes` (default 5) as well, sharing the same lock, so
237
+ cron and the hub can coexist without firing twice. It is off by default: cron or
238
+ systemd is the documented driver.
239
+
240
+ See [cli.md](cli.md) for the subcommands and flags, and
241
+ [configuration.md](configuration.md) for the `schedule.*` settings.
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
@@ -358,7 +360,31 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
358
360
  - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context`, `thinking`, `settings`, `toolkit`, `tools`, `no_tools` or `parent_answers` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task), and `moved_context`: `{moved: [names], stayed: [{name, why}]}`, the attached context it took (`why` `pushed` or `not movable`); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
359
361
  - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a one-turn `chi --non-interactive` run has, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `in use by a one-turn chi --non-interactive run or an older chi's terminal; let it finish`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link with a recap of its own reads its auto title, else its recap's first sentence, instead (a link without one reads its chain's name, and a first link's recap names its chain). A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or the `archived` toggle is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
360
362
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
361
- - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
363
+ - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain. A chain pruned by a schedule keeps its `anchor`: the kept window's oldest link still names a link that is gone, so the walk simply stops there; the page's chip counts from the surviving root (pruning renumbers the links) and its popover says `earlier links pruned` where the walk stops, rather than linking to a deleted session.
364
+
365
+ ## Schedules
366
+
367
+ A **schedule** is a prompt with a clock, kept in `<state dir>/schedules.json` and
368
+ fired by a tick that needs no worker — cron, systemd, `chi schedule tick`, or chi
369
+ web's hub when `schedule.auto_tick: true`. `chi schedule add --session ID
370
+ --weekday 1 --time 09:00 -m "…"` makes one; `chi schedule list|show|tick|run-now|enable|disable|cancel`
371
+ read and run it. Each firing is either the **next link of the session's chain**
372
+ (the default: the head is archived, a new link takes its place, and the links
373
+ behind `keep_links` are deleted with their delegates — archived links are outside
374
+ the retention sweep, so this is what bounds them) or a **turn in the session
375
+ itself** (`--mode same`, queued like a typed prompt). A target that is busy is
376
+ **skipped**, not failed: `next_due` stays, `skips` grows, and after
377
+ `max_skips` (12) consecutive skips or `max_failures` (3) consecutive failures the
378
+ schedule is disabled and `list` says why; any firing that succeeds resets both.
379
+
380
+ The difference from `register_reminder` is where the clock lives. A reminder is
381
+ in-memory state of a live worker: it holds that worker up (`/exit` asks about it),
382
+ maxes out at 1440 minutes, and survives only the short asked-restart hand-off. A
383
+ schedule is a file: it outlives every worker, wake, crash and archive, has no
384
+ interval limit, and never holds a worker up — a tick wakes one when none is
385
+ running. A schedule turn carries `client_id: "system:schedule:<id>`, so both UIs
386
+ label it `schedule`. Full details, the busy check and the DST caveat:
387
+ [Scheduling](scheduling.md).
362
388
 
363
389
  ## Review sessions
364
390
 
@@ -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"
@@ -116,11 +117,14 @@ module Samagotchi
116
117
  # without the worker exiting, for POST /holds/stop; returns the
117
118
  # HoldStop::Stopped the 200's +stopped+ carries; without one that route
118
119
  # answers 501
120
+ # @param on_activity [#call, nil] what the worker is busy with right now
121
+ # (Worker#activity_snapshot: the same signals WorkerIdleExit#hold reads)
122
+ # for GET /activity; without one that route answers 501
119
123
  def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
120
124
  port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
121
125
  heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
122
126
  on_command: nil, on_exit_request: nil, exit_discards: nil, exit_stopped: nil,
123
- on_holds: nil, on_holds_stop: nil)
127
+ on_holds: nil, on_holds_stop: nil, on_activity: nil)
124
128
  @engine = engine
125
129
  @on_input = on_input
126
130
  @on_command = on_command
@@ -129,6 +133,7 @@ module Samagotchi
129
133
  @exit_stopped = exit_stopped
130
134
  @on_holds = on_holds
131
135
  @on_holds_stop = on_holds_stop
136
+ @on_activity = on_activity
132
137
  @input_format = input_format
133
138
  @state_dir = state_dir
134
139
  @session_id = session_id
@@ -687,9 +692,12 @@ module Samagotchi
687
692
  # pair_turns: a participant's turn in a working session runs as a pair
688
693
  # turn (Guardrails::PairBoundary); a worker without it runs it as a
689
694
  # review turn, and chi web says so (Delivery::PAIR_FEATURE).
695
+ # activity: GET /activity answers with Worker#activity_snapshot, so
696
+ # `chi schedule tick` can ask a live worker what it is busy with.
690
697
  FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
691
698
  VerifyAnchor::FEATURE, ReviseAnchor::FEATURE,
692
- SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE, TurnDrawings::FEATURE].freeze
699
+ SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE, TurnDrawings::FEATURE,
700
+ WorkerLink::ACTIVITY_FEATURE].freeze
693
701
 
694
702
  # The routes, by method and what follows /session/:id/: a handler takes
695
703
  # the session id and the request body and returns [headers, status, body].
@@ -706,6 +714,7 @@ module Samagotchi
706
714
  %w[POST exit] => :handle_exit_request,
707
715
  %w[GET holds] => :handle_holds,
708
716
  %w[POST holds/stop] => :handle_holds_stop,
717
+ %w[GET activity] => :handle_activity,
709
718
  %w[POST recap] => :handle_recap,
710
719
  %w[POST relay] => :handle_relay,
711
720
  %w[POST relay/status] => :handle_relay_status,
@@ -1101,6 +1110,22 @@ module Samagotchi
1101
1110
  [{}, 200, { status: "stopped", session_id: @session_id, stopped: stopped.to_h }]
1102
1111
  end
1103
1112
 
1113
+ # GET /session/:id/activity: what this worker is busy with right now — the
1114
+ # same signals WorkerIdleExit#hold reads (a turn running, input queued, a
1115
+ # continue offer waiting, clients holding the stream, reminders, watched
1116
+ # tasks). A `chi schedule tick` runs out of process: it cannot read a
1117
+ # worker's memory, and the session files cannot see an open tab, so it
1118
+ # asks. Read-only and small: no task list, no transcript.
1119
+ # 200 {session_id, turn_running:, input_pending:, awaiting_continue:,
1120
+ # open_streams:, reminders:, tasks_holding:}; 501 activity_unavailable
1121
+ # when this worker has no such step.
1122
+ # Returns [headers, status, body].
1123
+ def handle_activity(_session_id, _body = nil)
1124
+ return [{}, 501, { error: "activity_unavailable" }] unless @on_activity
1125
+
1126
+ [{}, 200, { session_id: @session_id, **@on_activity.call }]
1127
+ end
1128
+
1104
1129
  # Queue a turn for this session through its file IPC (the worker polls
1105
1130
  # its input dir). Returns [headers, status, body]. Only this bridge's own
1106
1131
  # session: another id, in the path or the body, is refused as on every
@@ -1171,9 +1196,17 @@ module Samagotchi
1171
1196
  # A session command sent as a message (chi send -m "/model x", chi -p,
1172
1197
  # a UI whose command list lags) runs as the command, as typed in a
1173
1198
  # TUI; an unknown /word stays a prompt. Not with images: those are
1174
- # 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).
1175
1203
  if images.empty? && @on_command && command_registry.command?(prompt.to_s)
1176
- 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
1177
1210
  end
1178
1211
 
1179
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,
@@ -30,7 +30,8 @@ module Samagotchi
30
30
 
31
31
  status [<bundle>]
32
32
  Show provenance and each file's status for bundles. issues= counts what
33
- needs you: a file that stays behind, is missing or can't be read, not one you edited.
33
+ needs you: a file that stays behind, is missing or can't be read, or a rule that won't load;
34
+ not a memory or script you edited.
34
35
 
35
36
  diff <bundle> [file]
36
37
  Show diffs between base/current/incoming for a bundle.
@@ -265,6 +266,7 @@ module Samagotchi
265
266
  # @return [Array(MemoryBundle::PreparedSource, String), nil]
266
267
  def prepare_or_fail(expanded_source, word)
267
268
  prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
269
+ Samagotchi::MemoryBundle::Manifest::HooksRetired.refuse_source_files(prepared.dir)
268
270
  named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
269
271
  rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
270
272
  Samagotchi::MemoryBundle::Manifest::ValidationError => e
@@ -525,6 +527,8 @@ module Samagotchi
525
527
  @stdout.puts " Hooks (#{st.retired_hooks.size}): not loaded: #{Samagotchi::MemoryBundle::Manifest::HooksRetired::WORDS}"
526
528
  st.retired_hooks.each { |file, state| @stdout.puts " #{file} [#{state}]" }
527
529
  end
530
+ print_assets("Rules", st.rules, " (edited after install: not loaded; reinstall the bundle)")
531
+ print_assets("Scripts", st.scripts, " (edited after install: the next install replaces it, a copy kept in the trash)")
528
532
  if (plugin = st.plugin)
529
533
  note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
530
534
  @stdout.puts " Plugin: #{plugin[:file]} [#{plugin[:state]}]#{note}"
@@ -548,6 +552,8 @@ module Samagotchi
548
552
  plugin = st.plugin
549
553
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
550
554
  mods += 1 unless st.retired_hooks.empty?
555
+ # A rule that is missing or edited doesn't load; a script runs edited.
556
+ mods += st.rules.values.count { |state| state != "ok" } + st.scripts.values.count("missing")
551
557
  mods += 1 if st.scope_error
552
558
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
553
559
  includes = st.provenance.includes
@@ -558,6 +564,15 @@ module Samagotchi
558
564
  0
559
565
  end
560
566
 
567
+ # A bundle's rules or scripts (MemoryBundle::Status.asset_states), one
568
+ # line each, +modified+ said after an edited one.
569
+ def print_assets(heading, states, modified)
570
+ return if states.empty?
571
+
572
+ @stdout.puts " #{heading} (#{states.size}):"
573
+ states.each { |file, state| @stdout.puts " #{file} [#{state}]#{modified if state == "modified"}" }
574
+ end
575
+
561
576
  # "Manifest: [kept]", or "[from the install record]", and what its case
562
577
  # says (MemoryBundle::ManifestStanding).
563
578
  def manifest_line(record)
@@ -602,6 +617,8 @@ module Samagotchi
602
617
  diff_memory(prov, target_dir, file_arg)
603
618
  elsif plugin_file && [plugin_file, "plugin/#{plugin_file}"].include?(file_arg)
604
619
  diff_plugin(prov, record, plugin_path)
620
+ elsif (asset = asset_states(prov, record).find { |a| a.label == file_arg })
621
+ diff_asset(asset)
605
622
  else
606
623
  @stdout.puts "=== #{file_arg} ==="
607
624
  @stdout.puts "(not found in bundle)"
@@ -616,10 +633,31 @@ module Samagotchi
616
633
  diff_plugin(prov, record, plugin_path)
617
634
  @stdout.puts ""
618
635
  end
636
+ asset_states(prov, record).select(&:edited?).each do |a|
637
+ diff_asset(a)
638
+ @stdout.puts ""
639
+ end
619
640
  end
620
641
  0
621
642
  end
622
643
 
644
+ # The recorded rules and scripts (no source: an edited one is
645
+ # MemoryBundle::AssetState#edited?), for diff.
646
+ def asset_states(prov, record)
647
+ state = Samagotchi::MemoryBundle::AssetState
648
+ { rule: [prov.guardrails_dir, record.guardrails], script: [prov.scripts_dir, record.scripts] }.flat_map do |kind, (dir, recorded)|
649
+ recorded.keys.sort.map { |name| state.of(File.join(dir, name), kind_of_file: kind, recorded: recorded[name], name: name) }
650
+ end
651
+ end
652
+
653
+ # A rule or script keeps no base text: its recorded and current sha256.
654
+ def diff_asset(asset)
655
+ @stdout.puts "=== #{asset.label} ==="
656
+ current = File.file?(asset.path) ? Samagotchi::MemoryBundle::Provenance.file_sha(asset.path) : "(missing)"
657
+ edited = asset.edited? ? " (edited after install: the next install replaces it, a copy kept in the trash)" : ""
658
+ @stdout.puts "--- recorded sha256=#{Samagotchi::MemoryBundle::Provenance.recorded_sha(asset.recorded)} current sha256=#{current}#{edited}"
659
+ end
660
+
623
661
  # The base and the file; a marked one also the bundle's text it stays
624
662
  # behind (its source-less FileState says which).
625
663
  def diff_memory(prov, target_dir, key)
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.158.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