samagotchi 0.148.0 → 0.149.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/bundles.md +38 -1
  5. data/docs/cli.md +17 -0
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +2 -1
  9. data/docs/internals/objectives.md +264 -0
  10. data/docs/internals/web-frontend.md +4 -1
  11. data/docs/plugins.md +4 -2
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +42 -0
  14. data/docs/system-one.md +17 -1
  15. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
  16. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  17. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
  18. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  19. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  20. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/context_note.rb +17 -5
  24. data/lib/samagotchi/engine.rb +113 -6
  25. data/lib/samagotchi/engine_peers.rb +11 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  27. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  28. data/lib/samagotchi/note_command.rb +1 -1
  29. data/lib/samagotchi/note_delivery.rb +6 -2
  30. data/lib/samagotchi/objectives.rb +395 -0
  31. data/lib/samagotchi/objectives_audit.rb +115 -0
  32. data/lib/samagotchi/objectives_check.rb +335 -0
  33. data/lib/samagotchi/objectives_command.rb +192 -0
  34. data/lib/samagotchi/plugin/api.rb +2 -1
  35. data/lib/samagotchi/plugin/context.rb +21 -1
  36. data/lib/samagotchi/session_commands.rb +15 -0
  37. data/lib/samagotchi/session_continue.rb +3 -0
  38. data/lib/samagotchi/session_inbox.rb +14 -6
  39. data/lib/samagotchi/session_manager.rb +1 -1
  40. data/lib/samagotchi/steer.rb +24 -4
  41. data/lib/samagotchi/testing/boundary.rb +1 -0
  42. data/lib/samagotchi/testing/host.rb +1 -0
  43. data/lib/samagotchi/tool_activity.rb +17 -0
  44. data/lib/samagotchi/tool_declarations.rb +34 -0
  45. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  46. data/lib/samagotchi/tools/builtins.rb +4 -1
  47. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  48. data/lib/samagotchi/tools/peers.rb +3 -0
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/message_display.rb +9 -0
  51. data/lib/samagotchi/web/participant_view.rb +1 -1
  52. data/lib/samagotchi/web/public/app.js +10 -0
  53. data/lib/samagotchi/web/public/cards_place.js +20 -4
  54. data/lib/samagotchi/web/public/commands.js +4 -1
  55. data/lib/samagotchi/web/public/index.html +2 -0
  56. data/lib/samagotchi/web/public/objectives.css +80 -0
  57. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  58. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  59. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  60. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  61. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  62. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  63. data/lib/samagotchi/web/public/turn_html.js +4 -1
  64. data/lib/samagotchi/web/session_show.rb +12 -1
  65. data/lib/samagotchi/worker.rb +3 -0
  66. data/lib/samagotchi/worker_inbound.rb +16 -8
  67. metadata +15 -1
data/docs/system-one.md CHANGED
@@ -3,7 +3,7 @@
3
3
  System 1 is the cheap judgment model chi asks small questions: approvals
4
4
  ([guardrails.md](guardrails.md#system-1-experimental)), toolkit suggestions
5
5
  ([configuration.md](configuration.md#toolkits)), context candidates
6
- ([context.md](context.md)) and bundles' own ([plugins.md](plugins.md#judgments-chijudgment-and-ctxjudge)). Each use keeps its own mode, gates and log. What they share is
6
+ ([context.md](context.md)), the objectives' audit ([below](#objectives-in-audit)) and bundles' own ([plugins.md](plugins.md#judgments-chijudgment-and-ctxjudge)). Each use keeps its own mode, gates and log. What they share is
7
7
  one host (`system_one.host`, `.model`, `.timeout_ms`) and one layer that saves every ask, so a later comparison or fine-tune has what
8
8
  was sent and what came of it. The runs don't leave the machine; a hosted System 1 host sees each state as it is asked.
9
9
 
@@ -20,6 +20,7 @@ one warn and never changes the answer. A use that needs the run's id at once pas
20
20
  | `approvals.allow` | `GuardrailWiring#system_one_judge` | gates, acted (the judgment line's decision), labels |
21
21
  | `toolkit.suggest` | `ToolkitSuggest.judge` | acted (the log's `did`), no labels |
22
22
  | `context.candidate` | `ContextJudge.ask` | labels |
23
+ | `objectives.incoming`, `objectives.outgoing` | `ObjectivesAudit` | none: nothing acts on them |
23
24
  | `<bundle>.<name>` | `SystemOne::Judgments` (`ctx.judge`, `ctx.judge_later`) | the bundle's: labels, acted |
24
25
 
25
26
  Not saved: a toolkit warm-up (it goes straight to the backend), `chi guardrails replay`,
@@ -41,6 +42,21 @@ seam is common. A bundle's `judge_later` runs on the Engine's `SideJudgments` un
41
42
  at shutdown covers it, and a run cut by that one-second limit is lost. Its asks stay out of the session's spend, as all of System 1's do, and no
42
43
  cost cap covers them on a hosted host.
43
44
 
45
+ ## Objectives in audit
46
+
47
+ `objectives.system_one: audit` asks whether a message may affect an objective: one yes/no (`affects`) per active objective, each ask
48
+ one request and one run. It is off by default, and asks only with a list that has an active objective, a `system_one.host` and saving on
49
+ (`system_one.runs`). **Mode audit only:** there is no `on`. Nothing reads an answer: no card, no wake, no push, nothing in a prompt, and no turn. A
50
+ per-message judge on an objective didn't work as a gate on the pod (AUC about .5 to .66), so these runs are there to look at, with `chi judgments report objectives.incoming`.
51
+
52
+ - `objectives.incoming`: a person's message of the owner's of 40 characters or more at its turn's start (`TurnOrigin#human?`: not a reminder's or a delegate's prompt), not a `/` or `!` line, and a note going
53
+ into the conversation (not the list's own, nor a continue's carry note: `Objectives.outside_note?`). A steer isn't asked about.
54
+ - `objectives.outgoing`: a finished answer of the owner's turn of 400 characters or more, not a cancelled or exhausted one.
55
+ - The state: `objective` (its text and done-when, no id or status) and `message` (2000 characters: a message's head, an answer's tail).
56
+ **That text goes to the System 1 host**, a hosted one too, and into the run. A participant's turn, a review session and a scratch session are never asked about.
57
+ - At most 6 objectives per message. Each direction has one ask running: what comes meanwhile is a run with `asked: false`, `skipped: busy`.
58
+ - The asks run on `SideJudgments`' threads beside the turn, so a turn doesn't wait; on a one-slot server shared with the session's model, a request can still queue behind or ahead of them. They share `/v1/systemone` with approvals: on a slow one-slot host an approval can wait behind up to 2 in-flight asks and time out to the user. A failed ask is a run with its `failure` and one log line (`system_one_failed`), and a `--no-shared` REPL's stderr shows that line as for any System 1 ask.
59
+
44
60
  ## The store
45
61
 
46
62
  `<state dir>/system_one/runs/<judgment>/YYYY-MM-DD.jsonl` (`SystemOne::RunsStore`): one file per UTC day,
@@ -1,16 +1,16 @@
1
1
  ---
2
2
  name: coordinator
3
- version: 0.7.0
3
+ version: 0.8.0
4
4
  scope: system
5
- description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, merges under a guardrail approval (the git-merge rule) or a question, a handoff memory to resume from), /coordinate <goal>, /coordinate resume, /coordinate end, /coordinate review (experimental: a sprint review session to share) and /children; the coordinator toolkit (lean + delegate, delegate_result, list_sessions) and the coding toolkit for build children (child + be concise)"
5
+ description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, merges under a guardrail approval (the git-merge rule) or a question, a handoff memory to resume from), /coordinate <goal>, /coordinate resume, /coordinate end, /coordinate review (experimental: a sprint review session to share) and /children; the coordinator toolkit (lean + delegate, delegate_result, list_sessions, objectives) and the coding toolkit for build children (child + be concise)"
6
6
  trust_level: reviewed
7
7
  files:
8
8
  skill_coordinator.md:
9
- sha256: sha256:50b6dc3c2eca885133af9c6518964e6fe862194f2fa4556d9ee5374dde42242a
9
+ sha256: sha256:1cac885facd28757aaa0197b28889246f845834c17101d59b5625d1b2b89679a
10
10
  description: "Coordinate parallel work: split it, delegate each task wait:false in its own worktree (cwd:), check each report, merge under the git-merge approval or ask the user; resume from a handoff_* memory"
11
11
  plugin:
12
12
  file: plugin.rb
13
- sha256: sha256:fa5d7f8b01e465d80012e141abcb2ee27cd5cec43e5a74b9721d435be92d3730
13
+ sha256: sha256:84535a86b754f3a12cdb2f2591027ea71bbf9ffb74734b1050899b9a4fde07ac
14
14
  # the coordinator toolkit needs chi.toolkit and ctx.use_toolkit (0.66.0); the
15
15
  # coding toolkit (chi.toolkit prompt:) and the skill's delegate toolkit: need
16
16
  # the next chi.
@@ -18,3 +18,5 @@ requires_chi: ">= 0.107.0"
18
18
  # /coordinate review uses ctx.sessions.review (the next chi).
19
19
  # No requires_chi_next for guardrails/merge.yml: its keys are long supported, and first-ever too-new rules would make a
20
20
  # live older worker deny every call. A chi without `chi guardrails rule` gives a usage error, and the skill then asks.
21
+ # Nor for the objectives tool in the kit and the skill: on an older chi the kit's add names nothing (it declares only what
22
+ # is registered), and a call of it comes back as an unknown tool, no harm.
@@ -42,7 +42,8 @@
42
42
  # itself (the core child-boundary guardrail), in every mode: nothing to set up.
43
43
  #
44
44
  # The coordinator toolkit is lean plus the tools that run children
45
- # (delegate, delegate_result, list_sessions). /coordinate puts the session
45
+ # (delegate, delegate_result, list_sessions) and objectives (the skill's
46
+ # moves). /coordinate puts the session
46
47
  # on it before it asks the model (ctx.use_toolkit), which chi allows only
47
48
  # before the session's first model reply and outside a turn: otherwise it
48
49
  # says to run /toolkit coordinator, unless the session declares delegate
@@ -84,7 +85,7 @@ class Plugin
84
85
  def initialize(_settings = {}); end
85
86
 
86
87
  TOOLKIT = "coordinator"
87
- TOOLKIT_TOOLS = %w[delegate delegate_result list_sessions].freeze
88
+ TOOLKIT_TOOLS = %w[delegate delegate_result list_sessions objectives].freeze
88
89
  CODING_TOOLKIT = "coding"
89
90
  CODING_PROMPT = "- Be concise in your responses\n- Show file paths clearly when working with files"
90
91
 
@@ -109,6 +109,13 @@ If the request is a new goal, say which handoffs are open (one line each) and go
109
109
  description to "DONE: …" and ask the user whether to remove it (memory_write name, scope and
110
110
  remove: true).
111
111
 
112
+ ## Objectives
113
+ If this session has an OBJECTIVES note: when a branch's verdict, merge or blocker moves an objective, record it with
114
+ `objectives` (action: move, the id, a one-line note, evidence: a sha or a test count), not per command.
115
+ A move or a `done` is never approval to merge. A user's "not yet" on an objective a branch serves, or their changing
116
+ what it asks, counts as "the user said to hold" that branch (step 6), until the user says otherwise; their marking it
117
+ done isn't a hold. The user edits the list with /objectives; you don't.
118
+
112
119
  ## End of day
113
120
  When the user ends the day (`/coordinate end`, "that's all for today"), before you answer:
114
121
  1. Check where things stand: for each branch in your handoff `git log --oneline <default>..<branch>` and
@@ -167,3 +174,5 @@ participants: a manager or a teammate brings new context (a priority, a scope ch
167
174
  owner's Apply updates the handoff and re-plans, nothing started or merged there (0.6.0)
168
175
  - 2026-11-04 step 6: a branch verified with no doubts that can fast-forward merges with no question when chi's git-merge
169
176
  rule asks: the merge is chi's approval to give or pass; anything else asks as before (0.7.0)
177
+ - 2026-11-04 Objectives: record a move with the objectives tool; a move or a done never approves a merge; a user's
178
+ "not yet" or changed objective is a hold until they say otherwise (0.8.0)
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: decision-card
3
+ version: 0.2.0
4
+ scope: system
5
+ description: "In a session with objectives, a long answer ending in a question gets a card: a short summary and the question, answered in the composer"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:e628d84ab9521f7e8e5f3bc69da97fe00e574082412ea24e9f3f1c6234ad31a9
10
+ # ctx.objectives and ctx.turn_running? are new after 0.148.0
11
+ requires_chi: ">= 0.149.0"
12
+ # config.yml bundles: decision-card: (another key warns; an older chi ignores this)
13
+ settings: [min_chars, tail_chars, model, timeout, max_tokens]
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The decision-card bundle (docs/bundles.md, The decision-card bundle): in a
4
+ # session with objectives, an answer that is long and ends in a question gets a
5
+ # card with a short summary and the question, so the question isn't lost at the
6
+ # bottom of a long read. The user answers in the composer: the card has no
7
+ # buttons, so it invents no options, and it asks nothing of anyone.
8
+ #
9
+ # The gate costs nothing: the answer's length and tail, read from the event
10
+ # (event[:answer]: the stored message may still hold the native loop's thought),
11
+ # then the list (ctx.objectives). Only then one side request (ctx.ask_model), on
12
+ # a thread: the turn's end isn't held, and the web's answer doesn't wait for it.
13
+ # Nothing goes in the conversation: the card is the Bridge's (cards.json, capped),
14
+ # and uninstalling the bundle leaves a session as it was.
15
+ #
16
+ # A card may be late: a one-slot local server answers a side request after the
17
+ # running one. A new turn means the user replied or moved on, so it cancels the
18
+ # request (which frees the slot for that turn), and a card still to come is
19
+ # dropped. One already shown folds to a line at the owner's next turn. The request
20
+ # starts at once, ahead of the turn-end warm-up, so that the warm-up is the last
21
+ # thing in a one-slot server's slot. The card waits for the turn to be over (the
22
+ # hook runs inside it): shown earlier, it would belong to that turn's block, which
23
+ # the web collapses.
24
+ #
25
+ # A failed request shows nothing: it is only logged.
26
+ #
27
+ # /short is the same seam on demand: the last answer's short version, in a card,
28
+ # for any long answer (the web's objectives offer it), its model and its limits
29
+ # the card's. Nothing enters the conversation here either.
30
+ #
31
+ # Settings (config.yml, bundles: decision-card:):
32
+ # min_chars: 1200 how long an answer is long
33
+ # tail_chars: 300 how far from the end the question may be
34
+ # model: nil a cheap configured model ("host:id", an alias); nil is the session's
35
+ # timeout: 120 seconds the side request may take
36
+ # max_tokens: 300 the side answer's limit
37
+ class Plugin
38
+ TITLE = "A question for you"
39
+ SYSTEM = "You read the end of a working session for a busy user. You only report what is written; you don't " \
40
+ "continue the task, and you don't answer the question."
41
+ # What the side request asks, in the three parts #parse reads.
42
+ REQUEST = <<~PROMPT
43
+ The assistant's last answer above may end by asking the user something. The session's objectives are:
44
+
45
+ %<list>s
46
+
47
+ Reply with exactly these three lines:
48
+ SUMMARY: what the answer says, in one or two plain sentences, at most 40 words
49
+ QUESTION: the question it asks the user, in one sentence, in its own words
50
+ OBJECTIVES: the ids of the objectives that question concerns, or none
51
+
52
+ If it asks the user nothing they have to decide, write QUESTION: none. Don't offer options or say which you would pick.
53
+ PROMPT
54
+ PART = /\A[\s*_#>-]*(summary|question|objectives)[\s*_]*:[\s*_]*(.*?)[\s*_]*\z/i
55
+ OBJECTIVE_ID = /\bO\d+\b/i
56
+ SUMMARY_CHARS = 500
57
+ QUESTION_CHARS = 300
58
+ # An :info card with no actions and one line up to 160 characters is a notice row (docs/plugins.md, Cards).
59
+ FOLDED_CHARS = 160
60
+ # A question mark that ends a word (not a URL's), with the marks that may close it.
61
+ SHORT_TITLE = "Short version"
62
+ SHORT_CUT_OFF = "No short version: the session stopped before it came."
63
+ SHORT_BUSY = "a turn is running: /short works on its answer, after this turn"
64
+ # Under the answer. Two paragraphs also keep it a card: a short body alone is a collapsed notice row.
65
+ SHORT_NOTE = "_Asked on demand, not added to the conversation._"
66
+ SHORT_REQUEST = "Give the assistant's last answer above in two or three plain sentences, at most 60 words. Say what it " \
67
+ "found or did, and anything it asks the user. Add nothing it doesn't say."
68
+ QUESTION_MARK = /[??؟](?=[\s"'”’»」』)*_\]…]|\.(?:\s|\z)|\z)/
69
+ CODE = /```.*?(?:```|\z)|~~~.*?(?:~~~|\z)|`[^`\n]*`|^(?: {4}|\t)[^\n]*/m
70
+ # What a side answer says for "nothing to decide", however it words it.
71
+ NO_QUESTION = %r{\A(?:none\b|n/a\b|no questions?\b|there (?:is|are) no questions?\b)}i
72
+ TURN_END_POLL = 0.02
73
+
74
+ # One answer's side request: the session it is for, and how to cancel it.
75
+ Ask = Data.define(:session_id, :cancel)
76
+ # A card on screen, to fold when the owner's next turn starts.
77
+ Shown = Data.define(:id, :question)
78
+ # What the side answer said: Item ids the list has, in its order.
79
+ Reading = Data.define(:summary, :question, :ids)
80
+
81
+ def initialize(settings = {})
82
+ @min_chars = positive(settings["min_chars"]) || 1200
83
+ @tail_chars = positive(settings["tail_chars"]) || 300
84
+ model = settings["model"].to_s.strip
85
+ @model = model.empty? ? nil : model
86
+ @timeout = positive_number(settings["timeout"]) || 120
87
+ @max_tokens = positive(settings["max_tokens"]) || 300
88
+ @mutex = Mutex.new
89
+ @ask = nil
90
+ @shown = []
91
+ @shorts = {}
92
+ @thread = nil
93
+ end
94
+
95
+ def register(chi)
96
+ chi.on(:before_turn) { |event, ctx| turn_started(event, ctx) }
97
+ chi.on(:after_turn) { |event, ctx| turn_ended(event, ctx) }
98
+ chi.service(:short_requests, eager: true) do |svc|
99
+ svc.on_stop { @mutex.synchronize { @shorts.keys }.each { |id| end_short(id, SHORT_CUT_OFF) } }
100
+ true
101
+ end
102
+ chi.command("/short", "the last answer's short version, in a card; nothing enters the conversation", anytime: true) do |_args, ctx|
103
+ short(ctx)
104
+ end
105
+ end
106
+
107
+ # The side request's thread is done. Nothing in chi waits for it: it is for specs.
108
+ def settle = @thread&.join
109
+
110
+ private
111
+
112
+ # Any turn makes a request in flight stale, and the slot matters. Only the
113
+ # owner's answers a shown card: a reminder or a child's report has no prompt
114
+ # (a wake runs as a continue), and a participant's has an author.
115
+ def turn_started(event, ctx)
116
+ owner = !event[:author] && !event[:prompt].to_s.strip.empty?
117
+ ask, shown = @mutex.synchronize do
118
+ taken = [@ask, owner ? @shown : []]
119
+ @ask = nil
120
+ @shown = [] if owner
121
+ taken
122
+ end
123
+ ask&.cancel&.cancel!(:new_turn)
124
+ shown.each { |card| fold(ctx, card) }
125
+ end
126
+
127
+ def fold(ctx, shown)
128
+ ctx.card(id: shown.id, title: "Asked", body: cut(shown.question, FOLDED_CHARS))
129
+ end
130
+
131
+ # On the command's own thread. Not beside a turn: ctx.messages holds that turn so
132
+ # far, so its "last answer" would be interim text.
133
+ # @return [String, nil] what the command answers: only why there is no card
134
+ def short(ctx)
135
+ return SHORT_BUSY if ctx.turn_running?
136
+
137
+ messages = ctx.messages
138
+ answer = last_answer(messages) or return "no answer to shorten yet"
139
+
140
+ id = ctx.card(title: SHORT_TITLE, body: "…")
141
+ @mutex.synchronize { @shorts[id] = ctx }
142
+ reply = ctx.ask_model(messages: exchange(last_prompt(messages), answer), prompt: SHORT_REQUEST, system: SYSTEM,
143
+ timeout: @timeout, max_tokens: @max_tokens, model: @model)
144
+ end_short(id, "#{reply.strip}\n\n#{SHORT_NOTE}")
145
+ nil
146
+ rescue StandardError => e
147
+ end_short(id, "No short version: #{e.message}") if id
148
+ nil
149
+ end
150
+
151
+ # Once per card, whichever comes first: its answer, or the worker leaving with
152
+ # the request still out (chi waits 3 s for a command, then this service stops,
153
+ # while a card can still reach the UIs). A worker killed outright runs neither:
154
+ # its "…" stays, a one-line row.
155
+ def end_short(id, body)
156
+ ctx = @mutex.synchronize { @shorts.delete(id) } or return
157
+
158
+ ctx.card(id: id, title: SHORT_TITLE, body: body)
159
+ end
160
+
161
+ # @return [String, nil] the newest non-empty answer's text
162
+ def last_answer(messages)
163
+ found = Array(messages).reverse_each.find do |message|
164
+ %w[model assistant].include?(field(message, :role).to_s) && !field(message, :content).to_s.strip.empty?
165
+ end
166
+ found && field(found, :content).to_s
167
+ end
168
+
169
+ def turn_ended(event, ctx)
170
+ return unless event[:status].to_s == "completed"
171
+ # A participant's question is for the participant, on the owner's model; a
172
+ # one-shot run ends before any card is read.
173
+ return if event[:author] || ctx.frontend == :one_shot
174
+
175
+ answer = event[:answer]
176
+ return unless answer && answer.length >= @min_chars && question_at_tail?(answer)
177
+
178
+ prompt = last_prompt(event[:messages])
179
+ ask = Ask.new(session_id: ctx.session_id, cancel: Samagotchi::CancellationController.new)
180
+ @mutex.synchronize { @ask = ask }
181
+ @thread = Thread.new { ask_for_card(ctx, ask, answer, prompt) }
182
+ end
183
+
184
+ # @return [String, nil] the owner's text the turn answers
185
+ def last_prompt(messages)
186
+ prompt = Array(messages).reverse_each.find { |message| field(message, :role).to_s == "user" && field(message, :kind).to_s.empty? }
187
+ prompt && field(prompt, :content).to_s
188
+ end
189
+
190
+ def field(message, key) = message[key] || message[key.to_s]
191
+
192
+ def question_at_tail?(answer)
193
+ prose = answer.gsub(CODE, "").rstrip
194
+ prose[-[@tail_chars, prose.length].min..].to_s.match?(QUESTION_MARK)
195
+ end
196
+
197
+ # On its own thread. Nothing here reaches the user but the card.
198
+ def ask_for_card(ctx, ask, answer, prompt)
199
+ list = ctx.objectives
200
+ return unless list.items.any? { |item| item.status == "active" }
201
+
202
+ reply = ctx.ask_model(messages: exchange(prompt, answer), prompt: format(REQUEST, list: list.text), system: SYSTEM,
203
+ timeout: @timeout, max_tokens: @max_tokens, cancel: ask.cancel, model: @model)
204
+ reading = parse(reply, list)
205
+ return ctx.log.info("no_card", reason: "the side answer named no question and summary") unless reading
206
+
207
+ sleep(TURN_END_POLL) while ctx.turn_running? && current?(ask)
208
+ show(ctx, ask, reading, list)
209
+ rescue Samagotchi::Plugin::ModelCancelled
210
+ ctx.log.info("no_card", reason: "a new turn started")
211
+ rescue StandardError => e
212
+ ctx.log.warn("no_card", reason: "#{e.class}: #{e.message}"[0, 300])
213
+ end
214
+
215
+ def exchange(prompt, answer)
216
+ [prompt && { role: "user", content: prompt }, { role: "model", content: answer }].compact
217
+ end
218
+
219
+ def current?(ask) = @mutex.synchronize { @ask.equal?(ask) }
220
+
221
+ # Under the mutex, as a new turn's start is: a card shows before that start
222
+ # takes it (and folds it), or not at all.
223
+ def show(ctx, ask, reading, list)
224
+ @mutex.synchronize do
225
+ next ctx.log.info("no_card", reason: "a new turn started") unless @ask.equal?(ask) && ctx.session_id == ask.session_id
226
+
227
+ @ask = nil
228
+ id = ctx.card(title: title(reading), body: body(reading, list))
229
+ @shown << Shown.new(id: id, question: reading.question)
230
+ end
231
+ end
232
+
233
+ def title(reading) = reading.ids.empty? ? TITLE : "#{TITLE} · #{reading.ids.join(", ")}"
234
+
235
+ def body(reading, list)
236
+ concerns = reading.ids.map { |id| "Concerns #{id} #{list.find(id).text}" }
237
+ [reading.summary, "**#{reading.question}**", *concerns].join("\n\n")
238
+ end
239
+
240
+ # The reply's three parts, however a small model marked them up. Lines after a
241
+ # part's own belong to it.
242
+ # @return [Reading, nil] nil without both a summary and a question
243
+ def parse(reply, list)
244
+ parts = Hash.new { |hash, key| hash[key] = [] }
245
+ current = nil
246
+ reply.to_s.each_line do |line|
247
+ if (part = line.strip.match(PART))
248
+ current = part[1].downcase
249
+ parts[current] << part[2]
250
+ elsif current
251
+ parts[current] << line.strip
252
+ end
253
+ end
254
+ summary = clean(parts["summary"], SUMMARY_CHARS)
255
+ question = clean(parts["question"], QUESTION_CHARS)
256
+ return nil if summary.empty? || question.empty? || question.match?(NO_QUESTION)
257
+
258
+ Reading.new(summary: summary, question: question, ids: ids(parts["objectives"], list))
259
+ end
260
+
261
+ def clean(lines, limit) = cut(lines.join(" ").gsub(/[[:space:]]+/, " ").strip, limit)
262
+
263
+ # Display only: a named id the list has and hasn't dropped.
264
+ def ids(lines, list)
265
+ named = lines.join(" ").scan(OBJECTIVE_ID).map(&:upcase)
266
+ list.items.reject { |item| item.status == "dropped" }.map(&:id) & named
267
+ end
268
+
269
+ def cut(text, limit) = text.length > limit ? "#{text[0, limit - 1]}…" : text
270
+
271
+ def positive_number(value)
272
+ number = Float(value.to_s, exception: false)
273
+ number&.positive? ? number : nil
274
+ end
275
+
276
+ def positive(value)
277
+ number = Integer(value.to_s, exception: false)
278
+ number&.positive? ? number : nil
279
+ end
280
+ end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.7.4
3
+ version: 0.7.5
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.140.0"
7
+ requires_chi: ">= 0.149.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -17,3 +17,4 @@ includes:
17
17
  - codex-peer
18
18
  - ruby
19
19
  - model-notes
20
+ - decision-card
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.148.0
3
+ version: 0.149.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:
@@ -241,6 +241,12 @@ module Samagotchi
241
241
  Entry.new(key: "context.candidates.offer_threshold", yaml_path: %w[context candidates offer_threshold], type: :float, default: 0.6, expose: %i[config], lifecycle: :next_matching_turn_display_poll_write),
242
242
  Entry.new(key: "context.candidates.reask_after_turns", yaml_path: %w[context candidates reask_after_turns], type: :integer, default: 3, expose: %i[config], lifecycle: :next_matching_turn_display_poll_write),
243
243
 
244
+ # Check this (ObjectivesCheck): a cheap configured model for its one side request; nil is the session's.
245
+ Entry.new(key: "objectives.check_model", yaml_path: %w[objectives check_model], type: :string, default: nil, expose: %i[config], lifecycle: :next_matching_turn_display_poll_write),
246
+ # System 1 on the objectives (ObjectivesAudit): audit saves a yes/no per active objective on what comes in and goes
247
+ # out, acted on by nothing; off (no conversation text goes to a System 1 host for it). Host and model: system_one.*.
248
+ Entry.new(key: "objectives.system_one", yaml_path: %w[objectives system_one], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit], lifecycle: :next_matching_turn_display_poll_write),
249
+
244
250
  # What the model is sent of the conversation (LLMContextStrategy, LLMContextView): none, or a list of layers
245
251
  # (stale, forget; "|"-separated or a YAML list); forget (the model's forget_outputs) is experimental.
246
252
  # models.<key>.llm_context_strategy and hosts.<name>.llm_context_strategy come first.
@@ -416,7 +422,7 @@ module Samagotchi
416
422
  # Names core keeps, as a judgment's first word: a bundle can't declare one and
417
423
  # system_one.judgments can't set one (Record and Similar are model-blind, so a
418
424
  # swapped model would inherit a clean record and could auto-allow).
419
- RESERVED_JUDGMENTS = %w[approvals context guardrails toolkit].freeze
425
+ RESERVED_JUDGMENTS = %w[approvals context guardrails objectives toolkit].freeze
420
426
 
421
427
  # What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
422
428
  BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
@@ -16,11 +16,19 @@ module Samagotchi
16
16
  # context_source: the attached context source a note is about
17
17
  # (ContextAbsorber), so the UIs can tie it to its chip. A note's wall
18
18
  # time (MessageTime::KEY) is every entry's, kept beside these.
19
- KEYS = %i[kind note_id source from_session from_cwd context_source].freeze
19
+ # objectives: the list an objectives note carries (Objectives), string
20
+ # keys inside.
21
+ KEYS = %i[kind note_id source from_session from_cwd context_source objectives].freeze
20
22
  HEADER = "CONTEXT NOTE"
21
23
  # A finished background task's note (TaskFinishNote): a header of its own.
22
24
  TASK_HEADER = "TASK FINISHED"
23
- HEADERS = [HEADER, TASK_HEADER].freeze
25
+ # The session's objectives (Objectives): a frame that instructs by itself,
26
+ # so a session with no list sends no line about them.
27
+ OBJECTIVES_HEADER = "OBJECTIVES"
28
+ OBJECTIVES_FRAME = "[#{OBJECTIVES_HEADER}, set by the user. What the user wants this session to achieve; " \
29
+ "replaces any earlier list. Keep work on it. Say when something falls outside it. " \
30
+ "Don't change it yourself.]".freeze
31
+ HEADERS = [HEADER, TASK_HEADER, OBJECTIVES_HEADER].freeze
24
32
 
25
33
  FRAME_HEAD = /\A\[(?:#{HEADERS.map { |h| Regexp.escape(h) }.join("|")})[^\n]*\n/
26
34
 
@@ -30,18 +38,22 @@ module Samagotchi
30
38
  # note starts a turn chi runs for it (a context wake), as a wake turn's
31
39
  # delegate report does (Steer.turn_prompt?). +header+: the frame's
32
40
  # (HEADERS), not kept as a key: the content carries it. Its wall time
33
- # (MessageTime::KEY) is +created_at+'s, else now.
41
+ # (MessageTime::KEY) is +created_at+'s, else now. +objectives+: an
42
+ # objectives note's payload (Objectives::List#to_payload).
34
43
  # @return [Hash] the conversation message
35
44
  def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, context_source: nil,
36
- turn_start: nil, turn_id: nil, header: HEADER, **)
45
+ turn_start: nil, turn_id: nil, header: HEADER, objectives: nil, **)
37
46
  { role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
38
- from_cwd: from_cwd, context_source: context_source, turn_start: (true if turn_start == true), turn_id: turn_id,
47
+ from_cwd: from_cwd, context_source: context_source, objectives: objectives,
48
+ turn_start: (true if turn_start == true), turn_id: turn_id,
39
49
  content: frame(text, source: source, created_at: created_at, from_session: from_session,
40
50
  from_cwd: from_cwd, header: header),
41
51
  MessageTime::KEY => MessageTime.iso_of(created_at) || MessageTime.iso(MessageTime.now) }.compact
42
52
  end
43
53
 
44
54
  def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil, header: HEADER)
55
+ return "#{OBJECTIVES_FRAME}\n#{text}\n[END NOTE]" if header == OBJECTIVES_HEADER
56
+
45
57
  time = clock(created_at)
46
58
  from = label(source: source, from_session: from_session, from_cwd: from_cwd)
47
59
  "[#{header || HEADER} from #{from}#{", #{time}" if time}]\n#{text}\n[END NOTE]"