samagotchi 0.140.0 → 0.141.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.
@@ -173,9 +173,12 @@ module Samagotchi
173
173
  # over, the server's word on a --parent-answers looser than its
174
174
  # config.yml's (capped), and the id the prompt it started with gets for
175
175
  # its first turn (nil for one created idle).
176
- Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning, :first_turn_id) do
176
+ # +moved_context+: the attached context a continue took
177
+ # (ContextSources::Moved; nil from an older chi web).
178
+ Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning, :first_turn_id,
179
+ :moved_context) do
177
180
  def initialize(id:, model_warning: nil, continues: nil, moved_children: [], parent_answers_warning: nil,
178
- first_turn_id: nil) = super
181
+ first_turn_id: nil, moved_context: nil) = super
179
182
  end
180
183
 
181
184
  # A session's worker as an attach finds it (Local#worker, chi web's GET
@@ -255,16 +258,33 @@ module Samagotchi
255
258
  # "declined".
256
259
  ContextCandidate = Data.define(:candidate, :status)
257
260
 
261
+ # chi context add's outcome: the source's +name+, and
262
+ # +movable_dropped+ when a movable flag was given and the remote's chi
263
+ # web didn't keep it (its answer has no movable: older than the flag).
264
+ ContextAdded = Data.define(:name, :movable_dropped) do
265
+ def initialize(name:, movable_dropped: false) = super
266
+ end
267
+
258
268
  # chi context push's outcome: :pushed (+before+ the revision it had,
259
- # +revision+ the new one), :missing (no such source) or :not_push (a
260
- # command source, whose text is its command's).
261
- ContextPush = Data.define(:status, :before, :revision) do
262
- def initialize(status:, before: nil, revision: nil) = super
269
+ # +revision+ the new one), :missing (no such source), :not_push (a
270
+ # command source, whose text is its command's) or :continued (the
271
+ # session was continued, as +next_id+: nothing was written).
272
+ ContextPush = Data.define(:status, :before, :revision, :next_id) do
273
+ def initialize(status:, before: nil, revision: nil, next_id: nil) = super
263
274
 
264
275
  # Why a push to +name+ was refused as :not_push: a command source's
265
276
  # text is its command's, and a push between two fetches would read
266
277
  # as a change and then vanish.
267
278
  def self.not_push_message(name) = "#{name} runs a command, which writes its text: push goes to a --push source"
279
+
280
+ # Why a push to +name+ in session +id+ was refused as :continued: its
281
+ # writer holds the old id, and nothing reads a continued session.
282
+ # Generic (the CLI and chi web load no plugins): a peer's skill owns
283
+ # its re-pair.
284
+ def self.continued_message(name, id, next_id)
285
+ "#{id.to_s[0, 8]} was continued as #{next_id}: #{name} stays with the session it was attached to; " \
286
+ "attach it to #{next_id} (or pair again with #{next_id}) and push there"
287
+ end
268
288
  end
269
289
  end
270
290
  end
@@ -6,6 +6,7 @@ require_relative "session_setup"
6
6
  require_relative "session_inbox"
7
7
  require_relative "session_chain"
8
8
  require_relative "child_move"
9
+ require_relative "context_move"
9
10
  require_relative "archive_store"
10
11
  require_relative "recap_store"
11
12
  require_relative "bridge_client"
@@ -76,7 +77,8 @@ module Samagotchi
76
77
  # running, a prompt queued or the step-limit question refuse; an idle
77
78
  # worker is stopped), so a refusal starts nothing. Its open delegates
78
79
  # (running, waiting, live, an unreported reply, at any depth below them)
79
- # move to the new link instead (ChildMove): their reports reach it.
80
+ # move to the new link instead (ChildMove): their reports reach it. So
81
+ # does its movable attached context (ContextMove).
80
82
  # Before that its live worker is asked for a recap and given up to
81
83
  # +recap_wait+ seconds to write it.
82
84
  # One continue of a session at a time (a lock in its folder): a double
@@ -87,7 +89,8 @@ module Samagotchi
87
89
  # @param preview [String, nil] what the lists show before the first turn;
88
90
  # by default the prompt, else the previous link's preview
89
91
  # @return [Session] the new link (#model_warning as spawn_session's,
90
- # #moved_children: the delegates it took over, #opener:
92
+ # #moved_children: the delegates it took over, #moved_context: the
93
+ # attached context it took (ContextMove), #opener:
91
94
  # SessionChain.opener_for the previous link)
92
95
  # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
93
96
  # @raise [Refused] continued already, delegates open that can't
@@ -199,9 +202,12 @@ module Samagotchi
199
202
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
200
203
  start = prompt.to_s.strip.empty? ? nil : prompt
201
204
  preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
205
+ moved_context = nil
202
206
  before_spawn = lambda do |link|
203
207
  ChildMove.mark_starting(link.id, state_dir: state_dir)
204
208
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
209
+ # Its movable context too, with a note saying what moved and stayed.
210
+ moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
205
211
  end
206
212
  link = SessionManager.spawn_session(
207
213
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
@@ -210,6 +216,7 @@ module Samagotchi
210
216
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
211
217
  )
212
218
  link.moved_children = move
219
+ link.moved_context = moved_context
213
220
  link.opener = SessionChain.opener_for(previous)
214
221
  # The fallback's warning first: spawn_session set its own.
215
222
  warnings = [choice.warning, link.model_warning].compact
@@ -69,7 +69,11 @@ module Samagotchi
69
69
  'require "samagotchi/guardrails/context"' => "for GitInfo: a ctx helper for the checkout's files could replace it",
70
70
  "Samagotchi::Guardrails::GitInfo.new" => "the work tree's top for the AGENTS.md chain (ctx.repo_root covers the top only)"
71
71
  },
72
- "claude-peer" => PEER_SPEC,
72
+ # Both bundles' goldens live in claude-peer's spec/.
73
+ "claude-peer" => PEER_SPEC.merge(
74
+ "Samagotchi::ContextSources.move" => "spec: a continue leaving the pairing behind, as SessionContinue " \
75
+ "moves it: needs a kit helper that continues a session"
76
+ ),
73
77
  "codex-peer" => PEER_SPEC,
74
78
  "coordinator" => {
75
79
  "Samagotchi::MemoryPaths.scope_dir" => "where the coordinator's memories live: needs a ctx memory-path API",
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.140.0"
4
+ VERSION = "0.141.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1428,13 +1428,18 @@ module Samagotchi
1428
1428
  # +url+ (the page's only kind). As chi context add: {name, push: true,
1429
1429
  # why, hint}, a source chi context push fills, or {name, cmd, every,
1430
1430
  # why, hint}, a command chi runs every so often. added_by: "cli" or
1431
- # "agent" (chi context's), else "web". 201 {status: "attached", name};
1432
- # 400 invalid_json or invalid_source (a bad name or every), 422
1433
- # not_a_url or no_provider, 409 exists.
1431
+ # "agent" (chi context's), else "web". movable (any kind; optional):
1432
+ # whether a continue or a fork takes it (Source#movable?). 201
1433
+ # {status: "attached", name, movable: the source's, derived when not
1434
+ # given}; 400 invalid_json or invalid_source (a bad name, every or
1435
+ # movable), 422 not_a_url or no_provider, 409 exists.
1434
1436
  def handle_context_add(req, id)
1435
1437
  session = @session_class.load(id, state_dir: default_state_dir)
1436
1438
  body = parse_json(request_body(req))
1437
1439
  return error_response(400, "invalid_json", CONTEXT_ADD_BODIES) unless body.is_a?(Hash)
1440
+ unless [nil, true, false].include?(body["movable"])
1441
+ return error_response(400, "invalid_source", "movable is true or false")
1442
+ end
1438
1443
  return context_add_named(id, body) if body.key?("name")
1439
1444
  return error_response(400, "invalid_json", CONTEXT_ADD_BODIES) unless body["url"].is_a?(String)
1440
1445
 
@@ -1445,8 +1450,8 @@ module Samagotchi
1445
1450
  return error_response(422, "no_provider", "no installed bundle resolves #{url[0, 200]}") unless resolved
1446
1451
 
1447
1452
  own = ContextSources.session_location(session.id, state_dir: default_state_dir)
1448
- own.add(web_source(resolved, body, own.scope))
1449
- json_response(201, { status: "attached", name: resolved.name })
1453
+ source = own.add(web_source(resolved, body, own.scope))
1454
+ json_response(201, { status: "attached", name: resolved.name, movable: source.movable? })
1450
1455
  rescue ContextProviders::Invalid => e
1451
1456
  error_response(422, "no_provider", e.message)
1452
1457
  rescue ContextSources::Invalid => e
@@ -1469,12 +1474,13 @@ module Samagotchi
1469
1474
  ContextSources.check_name!(name)
1470
1475
  ContextSources::Source.new(name: name, cmd: cmd, every_seconds: ContextSources.check_every!(every),
1471
1476
  why: context_line(body["why"]), hint: context_line(body["hint"]), scope: nil,
1472
- added_by: context_added_by(body), created_at: Time.now.utc.iso8601)
1477
+ added_by: context_added_by(body), created_at: Time.now.utc.iso8601,
1478
+ movable: body["movable"])
1473
1479
  rescue ContextSources::Invalid => e
1474
1480
  return error_response(400, "invalid_source", e.message)
1475
1481
  end
1476
1482
  session_access.context_add(context_target(id), source)
1477
- json_response(201, { status: "attached", name: name })
1483
+ json_response(201, { status: "attached", name: name, movable: source.movable? })
1478
1484
  end
1479
1485
 
1480
1486
  def context_line(value) = ContextSources.one_line(value.is_a?(String) ? value : nil, ContextSources::LINE_MAX_CHARS)
@@ -1485,7 +1491,7 @@ module Samagotchi
1485
1491
  why: context_line(body["why"]) || resolved.why,
1486
1492
  hint: context_line(body["hint"]) || resolved.hint, scope: scope,
1487
1493
  added_by: context_added_by(body), created_at: Time.now.utc.iso8601,
1488
- provider: resolved.bundle)
1494
+ provider: resolved.bundle, movable: body["movable"])
1489
1495
  end
1490
1496
 
1491
1497
  # DELETE /api/sessions/:id/context/:name: the session's own source is
@@ -318,14 +318,22 @@ export function openChildrenTemplate(prevId, children, hrefFor) {
318
318
 
319
319
  // The toast after a continue: the new link, the previous one archived (when
320
320
  // the page shows the new one), how many open delegates moved to it (the
321
- // answer's moved), and the model fallback's warning (model_warning: the
322
- // previous link's host is gone, the link starts on the default).
321
+ // answer's moved), the attached context that moved and stayed (its
322
+ // moved_context; none from an older chi web), and the model fallback's
323
+ // warning (model_warning: the previous link's host is gone, the link starts
324
+ // on the default).
323
325
  export function continuedText(prevId, created, { opened = true } = {}) {
324
326
  const short = String(prevId).slice(0, 8);
325
327
  const moved = Array.isArray(created?.moved) ? created.moved.length : 0;
326
328
  const parts = [`Continued ${short} in ${String(created?.id || "").slice(0, 8)}`];
327
329
  if (opened) parts.push(`${short} is archived`);
328
330
  if (moved) parts.push(`${moved} open ${moved === 1 ? "delegate" : "delegates"} moved`);
331
+ const context = created?.moved_context;
332
+ const names = (list) => (Array.isArray(list) ? list : []).map((item) => (typeof item === "string" ? item : item?.name)).filter(Boolean);
333
+ const contextMoved = names(context?.moved);
334
+ const contextStayed = names(context?.stayed);
335
+ if (contextMoved.length) parts.push(`context moved: ${contextMoved.join(", ")}`);
336
+ if (contextStayed.length) parts.push(`context stayed with ${short}: ${contextStayed.join(", ")}`);
329
337
  const warning = typeof created?.model_warning === "string" ? created.model_warning.trim() : "";
330
338
  if (warning) parts.push(warning);
331
339
  return parts.join("; ");
@@ -450,8 +450,10 @@ module Samagotchi
450
450
  # hint}: new text for the session's --push source (chi context push;
451
451
  # the fields as a source's JSON output gives them, checked the same
452
452
  # way). 200 {status: "pushed", name, before, revision, changed}; 404
453
- # context_missing (no such source); 409 not_push (a command source); 400
454
- # invalid_text.
453
+ # context_missing (no such source); 409 not_push (a command source); 410
454
+ # context_continued (the session was continued, as next_id: never 409,
455
+ # which an older Remote reads as not_push, nor "continued", which it
456
+ # reads as a continue's refusal); 400 invalid_text.
455
457
  def handle_context_push(req, id, name)
456
458
  body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
457
459
  return error_response(400, "missing_fields", "text is required") unless body["text"].is_a?(String)
@@ -465,6 +467,9 @@ module Samagotchi
465
467
  case pushed.status
466
468
  when :missing then error_response(404, "context_missing", "no source #{name[0, 40]}")
467
469
  when :not_push then error_response(409, "not_push", SessionAccess::ContextPush.not_push_message(name))
470
+ when :continued
471
+ json_response(410, { error: "context_continued", next_id: pushed.next_id,
472
+ detail: SessionAccess::ContextPush.continued_message(name, id, pushed.next_id) })
468
473
  else json_response(200, { status: "pushed", name: name, before: pushed.before, revision: pushed.revision,
469
474
  changed: pushed.before != pushed.revision })
470
475
  end
@@ -180,6 +180,7 @@ module Samagotchi
180
180
  json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
181
181
  model_warning: session.model_warning,
182
182
  opener: session.opener,
183
+ moved_context: session.moved_context&.to_h,
183
184
  first_turn_id: session.first_turn_id))
184
185
  end
185
186
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.140.0
4
+ version: 0.141.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -304,6 +304,7 @@ files:
304
304
  - lib/samagotchi/context_command.rb
305
305
  - lib/samagotchi/context_fetch.rb
306
306
  - lib/samagotchi/context_judge.rb
307
+ - lib/samagotchi/context_move.rb
307
308
  - lib/samagotchi/context_note.rb
308
309
  - lib/samagotchi/context_poller.rb
309
310
  - lib/samagotchi/context_providers.rb