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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -7
- data/docs/cli.md +57 -2
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +24 -3
- data/docs/hooks.md +3 -0
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +46 -14
- data/docs/scheduling.md +241 -0
- data/docs/sessions.md +28 -2
- data/lib/samagotchi/bridge.rb +37 -4
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +39 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +17 -3
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/config.rb +12 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_command.rb +9 -3
- data/lib/samagotchi/context_move.rb +74 -0
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
- data/lib/samagotchi/memory_bundle/status.rb +22 -3
- data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +1008 -0
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +28 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +34 -13
- data/lib/samagotchi/session_manager/worker_command.rb +11 -23
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +42 -12
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +44 -2
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- 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/<
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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` (
|
|
17
|
-
`.chi/` dir, is the marker), and DIR and every subfolder share one
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
data/docs/scheduling.md
ADDED
|
@@ -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;
|
|
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
|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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:
|
|
10
|
+
memory_guide.md: sha256:bdd34f34063df1b4ae74f7cda7c31080986d155e535ee4526d673c70edac8292
|
|
11
11
|
delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
|