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.
@@ -23,13 +23,14 @@ module Samagotchi
23
23
  # sessions/<id>/muted/<name> a marker: this session ignores <name>
24
24
  # sessions/<id>/declined/<name> a marker (its hint inside): the user removed <name>
25
25
  # sessions/<id>/candidates/<name>.json a Candidate: a source a plugin proposed here (ctx.context.propose)
26
+ # sessions/<id>/left.json [Left]: what a continue left with the previous link (.move)
26
27
  # The guardrails protect the root (ProtectedPaths, CHI_TEXT): a source's
27
28
  # command runs later, outside the gate.
28
29
  module ContextSources
29
30
  DIR = "context"
30
31
  NAME_RE = /\A[a-z0-9][a-z0-9-]{0,39}\z/
31
- # subscriptions.json shares a session's folder with its sources.
32
- RESERVED_NAMES = %w[subscriptions].freeze
32
+ # subscriptions.json and left.json share a session's folder with its sources.
33
+ RESERVED_NAMES = %w[subscriptions left].freeze
33
34
  SCOPES = %w[session project].freeze
34
35
  MIN_EVERY_SECONDS = 30
35
36
  DEFAULT_EVERY_SECONDS = 300
@@ -38,6 +39,7 @@ module Samagotchi
38
39
  LINE_MAX_CHARS = 300
39
40
  SNAPSHOT_SUFFIX = ".snapshot.json"
40
41
  SUBSCRIPTIONS_FILE = "subscriptions.json"
42
+ LEFT_FILE = "left.json"
41
43
  MUTED_DIR = "muted"
42
44
  DECLINED_DIR = "declined"
43
45
  CANDIDATES_DIR = "candidates"
@@ -53,21 +55,32 @@ module Samagotchi
53
55
  # folder its cmd's {bundle_dir} names (ContextProviders.command_for).
54
56
  # +title+: what a person reads it as (a candidate's, when one was
55
57
  # attached; nil for most).
56
- Source = Data.define(:name, :cmd, :every_seconds, :why, :hint, :scope, :added_by, :created_at, :provider, :title) do
57
- def initialize(name:, cmd:, every_seconds:, why:, hint:, scope:, added_by:, created_at:, provider: nil, title: nil) = super
58
+ # +movable+: whether a continue or a fork takes it to the new session
59
+ # (ContextSources.move); nil derives it (#movable?).
60
+ Source = Data.define(:name, :cmd, :every_seconds, :why, :hint, :scope, :added_by, :created_at, :provider, :title,
61
+ :movable) do
62
+ def initialize(name:, cmd:, every_seconds:, why:, hint:, scope:, added_by:, created_at:, provider: nil, title: nil,
63
+ movable: nil) = super
58
64
 
59
65
  def push? = cmd.nil?
60
66
 
67
+ # Given (+movable+), else derived from who writes the text: a command
68
+ # source moves (chi runs its command, in the new session too); a
69
+ # pushed one stays (its writer holds the old session's id).
70
+ def movable? = movable.nil? ? !push? : movable
71
+
61
72
  def to_h
62
73
  { "name" => name, "cmd" => cmd, "every_seconds" => every_seconds, "why" => why, "hint" => hint,
63
74
  "scope" => scope, "added_by" => added_by, "created_at" => created_at, "provider" => provider,
64
- "title" => title }.compact
75
+ "title" => title, "movable" => movable }.compact
65
76
  end
66
77
 
67
78
  def self.from_h(data)
79
+ movable = data["movable"]
68
80
  new(name: data["name"].to_s, cmd: data["cmd"], every_seconds: data["every_seconds"]&.to_i,
69
81
  why: data["why"], hint: data["hint"], scope: data["scope"].to_s, added_by: data["added_by"],
70
- created_at: data["created_at"], provider: data["provider"], title: data["title"])
82
+ created_at: data["created_at"], provider: data["provider"], title: data["title"],
83
+ movable: [true, false].include?(movable) ? movable : nil)
71
84
  end
72
85
  end
73
86
 
@@ -179,10 +192,11 @@ module Samagotchi
179
192
  return [] unless Dir.exist?(dir)
180
193
 
181
194
  Dir.children(dir).sort.filter_map do |file|
182
- next unless file.end_with?(".json") && !file.end_with?(SNAPSHOT_SUFFIX) && file != SUBSCRIPTIONS_FILE
195
+ next unless file.end_with?(".json") && !file.end_with?(SNAPSHOT_SUFFIX)
183
196
 
184
197
  name = file.delete_suffix(".json")
185
- name.match?(NAME_RE) ? source(name) : nil
198
+ # subscriptions.json, left.json: not sources.
199
+ name.match?(NAME_RE) && !RESERVED_NAMES.include?(name) ? source(name) : nil
186
200
  end
187
201
  end
188
202
 
@@ -374,6 +388,18 @@ module Samagotchi
374
388
  AtomicFile.write(File.join(dir, SUBSCRIPTIONS_FILE), JSON.generate(subs.transform_values(&:to_h)))
375
389
  end
376
390
 
391
+ # What a continue left with the previous link (ContextSources.move).
392
+ # @return [Array<Left>] none without the file
393
+ def left
394
+ rows = ContextSources.read_json_value(File.join(dir, LEFT_FILE))
395
+ Array(rows).filter_map { |row| Left.from_h(row) if row.is_a?(Hash) && row["name"].is_a?(String) }
396
+ end
397
+
398
+ def write_left(rows)
399
+ FileUtils.mkdir_p(dir)
400
+ AtomicFile.write(File.join(dir, LEFT_FILE), JSON.generate(rows.map(&:to_h)))
401
+ end
402
+
377
403
  def update_subscription(name)
378
404
  subs = subscriptions
379
405
  subs[name] = yield(subs[name] || Subscription.blank(name))
@@ -382,8 +408,215 @@ module Samagotchi
382
408
  end
383
409
  end
384
410
 
411
+ # A source a move left behind (ContextSources.move): its +name+ and
412
+ # +why+, :pushed (derived: its writer holds the old id) or :not_movable
413
+ # (its movable: false).
414
+ Stayed = Data.define(:name, :why) do
415
+ def label = why == :pushed ? "pushed" : "not movable"
416
+
417
+ # The peer whose pairing this is ("Claude"), for a pushed
418
+ # <peer>-inbox or <peer>-outbox (the peer bundles' names: the move
419
+ # runs where no plugin is loaded, so the name is all there is; it
420
+ # only words the note, PeerSources decides what a peer is); nil for
421
+ # any other.
422
+ def peer
423
+ match = why == :pushed && PEER_NAME.match(name)
424
+ match ? match[1].capitalize : nil
425
+ end
426
+
427
+ def inbox? = name.end_with?("-inbox")
428
+ end
429
+ # A source a continue left with the previous link (+from+), as the new
430
+ # link's left.json keeps it: its +name+ and +hint+, the snapshot's (a
431
+ # pushed hint: a peer's re-pair pushes its new inbox there) else the
432
+ # source's. ctx.context.left_behind reads it (Plugin::Peer asks the peer
433
+ # to pair again at that inbox).
434
+ Left = Data.define(:name, :hint, :from) do
435
+ def to_h = { "name" => name, "hint" => hint, "from" => from }.compact
436
+
437
+ def self.from_h(data) = new(name: data["name"].to_s, hint: data["hint"]&.to_s, from: data["from"]&.to_s)
438
+ end
439
+
440
+ PEER_NAME = /\A([a-z][a-z0-9]*)-(?:inbox|outbox)\z/
441
+
442
+ # What ContextSources.move did: the names it took to the new session
443
+ # (+moved+) and the sources it left (+stayed+, Stayed).
444
+ Moved = Data.define(:moved, :stayed) do
445
+ def self.none = new(moved: [], stayed: [])
446
+
447
+ def empty? = moved.empty? && stayed.empty?
448
+
449
+ # The new session's note (one sentence each, no instructions); nil
450
+ # when nothing moved or stayed.
451
+ # @param from [String] the session the sources came from
452
+ def note_text(from)
453
+ return nil if empty?
454
+
455
+ lines = []
456
+ lines << "Context moved with this session: #{moved.join(", ")}." unless moved.empty?
457
+ lines << "Left with #{from.to_s[0, 8]}: #{left_words.join(", ")}." unless stayed.empty?
458
+ lines.join(" ")
459
+ end
460
+
461
+ # Each stayed source with why; a peer's pairing as one group, saying
462
+ # what chi does about it (Plugin::Peer posts the moved message to an
463
+ # inbox left behind; an outbox alone hears of it on its next push's
464
+ # refusal), never whether it worked.
465
+ def left_words
466
+ stayed.group_by { |s| s.peer || s }.values.map do |group|
467
+ peer = group.first.peer
468
+ next "#{group.first.name} (#{group.first.label})" unless peer
469
+
470
+ how = if group.any?(&:inbox?)
471
+ "#{peer}'s pairing: chi asks #{peer} to pair again with this session"
472
+ else
473
+ "#{peer} pairs again when it next answers"
474
+ end
475
+ "#{group.map(&:name).join(", ")} (#{how})"
476
+ end
477
+ end
478
+
479
+ # A UI's few words (chi send --new --continues); nil for nothing.
480
+ def words
481
+ parts = []
482
+ parts << "context moved: #{moved.join(", ")}" unless moved.empty?
483
+ parts << "context stayed: #{stayed.map(&:name).join(", ")}" unless stayed.empty?
484
+ parts.empty? ? nil : parts.join("; ")
485
+ end
486
+
487
+ # chi web's continue answer (moved_context).
488
+ def to_h = { "moved" => moved, "stayed" => stayed.map { |s| { "name" => s.name, "why" => s.label } } }
489
+
490
+ # @param data [Hash, nil] #to_h's; nil (an older chi web): nil
491
+ def self.from_h(data)
492
+ return nil unless data.is_a?(Hash)
493
+
494
+ stayed = Array(data["stayed"]).filter_map do |row|
495
+ next unless row.is_a?(Hash)
496
+
497
+ Stayed.new(name: row["name"].to_s, why: row["why"] == "pushed" ? :pushed : :not_movable)
498
+ end
499
+ new(moved: Array(data["moved"]).map(&:to_s), stayed: stayed)
500
+ end
501
+ end
502
+
385
503
  module_function
386
504
 
505
+ # Takes the movable sources of session +from+'s own location
506
+ # (Source#movable?) to session +to+, the new link of a continue or a
507
+ # plugin fork (+mode+ :continue or :fork), before +to+'s worker starts:
508
+ # each one's source and snapshot (a plain read: writes are AtomicFile
509
+ # renames; never Location#locked, which a fetch holds for its whole
510
+ # command), every declined/ and muted/ marker (the user's choices),
511
+ # and a subscription per source moved. A continue's says nothing seen
512
+ # (the new link's first absorb gives the Attached note) at the
513
+ # snapshot's serial (an old wake doesn't wake it). A fork's is what
514
+ # its +seed+ (the fork's messages) last had: the newest note about the
515
+ # source (its note_id, ContextAbsorber#text_note_id), else as a
516
+ # continue's. Never candidates/, lock files, or a source +to+ has
517
+ # already (it wins). A continue's link keeps what stayed in left.json
518
+ # (Left, Location#left).
519
+ # @param seed [Array<Hash>] a fork's messages
520
+ # @return [Moved]
521
+ def move(from:, to:, mode:, state_dir:, seed: nil)
522
+ raise ArgumentError, "mode is :continue or :fork, not #{mode.inspect}" unless %i[continue fork].include?(mode)
523
+
524
+ source_loc = session_location(from, state_dir: state_dir)
525
+ target = session_location(to, state_dir: state_dir)
526
+ return Moved.none unless Dir.exist?(source_loc.dir)
527
+
528
+ moved = []
529
+ stayed = []
530
+ left = []
531
+ subs = {}
532
+ source_loc.sources.each do |source|
533
+ unless source.movable?
534
+ stayed << Stayed.new(name: source.name, why: source.movable.nil? ? :pushed : :not_movable)
535
+ left << Left.new(name: source.name, hint: source_loc.snapshot(source.name).hint || source.hint, from: from)
536
+ next
537
+ end
538
+ next if File.exist?(target.source_path(source.name))
539
+
540
+ snapshot = copy_source(source, from: source_loc, to: target) or next
541
+ subs[source.name] = moved_subscription(source.name, snapshot, mode == :fork ? seed : nil)
542
+ moved << source.name
543
+ end
544
+ [DECLINED_DIR, MUTED_DIR].each { |folder| copy_markers(source_loc.dir, target.dir, folder) }
545
+ target.write_subscriptions(target.subscriptions.merge(subs)) unless subs.empty?
546
+ target.write_left(left) if mode == :continue && !left.empty?
547
+ Moved.new(moved: moved, stayed: stayed)
548
+ end
549
+
550
+ # The source's file and its snapshot's (when it has one), as they are.
551
+ # @return [Snapshot, nil] the snapshot copied (Snapshot.empty: none);
552
+ # nil when the source went meanwhile (nothing copied)
553
+ def copy_source(source, from:, to:)
554
+ source_raw = File.read(from.source_path(source.name))
555
+ snapshot_path = from.snapshot_path(source.name)
556
+ snapshot_raw = File.file?(snapshot_path) ? File.read(snapshot_path) : nil
557
+ FileUtils.mkdir_p(to.dir)
558
+ AtomicFile.write(to.snapshot_path(source.name), snapshot_raw) if snapshot_raw
559
+ AtomicFile.write(to.source_path(source.name), source_raw)
560
+ data = snapshot_raw && read_json_text(snapshot_raw)
561
+ data ? Snapshot.from_h(data) : Snapshot.empty
562
+ rescue SystemCallError
563
+ nil
564
+ end
565
+
566
+ def read_json_text(raw)
567
+ data = JSON.parse(raw)
568
+ data.is_a?(Hash) ? data : nil
569
+ rescue JSON::ParserError
570
+ nil
571
+ end
572
+
573
+ def copy_markers(from_dir, to_dir, folder)
574
+ source = File.join(from_dir, folder)
575
+ return unless Dir.exist?(source)
576
+
577
+ Dir.children(source).each do |name|
578
+ next unless name.match?(NAME_RE)
579
+
580
+ path = File.join(to_dir, folder, name)
581
+ next if File.exist?(path)
582
+
583
+ FileUtils.mkdir_p(File.dirname(path))
584
+ AtomicFile.write(path, File.read(File.join(source, name)))
585
+ rescue SystemCallError
586
+ next
587
+ end
588
+ end
589
+
590
+ # A moved source's subscription in the new session (#move).
591
+ def moved_subscription(name, snapshot, seed)
592
+ blank = Subscription.blank(name).with(seen_serial: snapshot.serial)
593
+ held = seed && seed_note(name, seed)
594
+ return blank unless held
595
+
596
+ serial, prefix = held
597
+ same = snapshot.serial == serial && snapshot.revision.to_s.start_with?(prefix)
598
+ # A partial revision differs from every full one: an Updated note follows.
599
+ Subscription.blank(name).with(seen: same ? snapshot.revision : prefix, seen_serial: serial)
600
+ end
601
+
602
+ # The serial and revision prefix of the newest text note about +name+
603
+ # in +messages+ (ContextAbsorber's note_id, ctx-<name>-<serial>-<rev12>).
604
+ # @return [Array(Integer, String), nil]
605
+ def seed_note(name, messages)
606
+ pattern = /\Actx-#{Regexp.escape(name)}-(\d+)-(\h{12})\z/
607
+ Array(messages).reverse_each do |message|
608
+ next unless message.is_a?(Hash)
609
+
610
+ context_source = message.key?(:context_source) ? message[:context_source] : message["context_source"]
611
+ next unless context_source == name
612
+
613
+ note_id = (message.key?(:note_id) ? message[:note_id] : message["note_id"]).to_s
614
+ match = pattern.match(note_id) or next
615
+ return [match[1].to_i, match[2]]
616
+ end
617
+ nil
618
+ end
619
+
387
620
  # <state dir>/context; +state_dir+ is the sessions dir (Session.default_state_dir).
388
621
  def root(state_dir: nil)
389
622
  File.join(File.dirname(state_dir || Session.default_state_dir), DIR)
@@ -523,10 +756,15 @@ module Samagotchi
523
756
  end
524
757
 
525
758
  def read_json(path)
759
+ data = read_json_value(path)
760
+ data.is_a?(Hash) ? data : nil
761
+ end
762
+
763
+ # The JSON value in +path+; nil when there is none or it isn't JSON.
764
+ def read_json_value(path)
526
765
  return nil unless File.file?(path)
527
766
 
528
- data = JSON.parse(File.read(path))
529
- data.is_a?(Hash) ? data : nil
767
+ JSON.parse(File.read(path))
530
768
  rescue JSON::ParserError, SystemCallError
531
769
  nil
532
770
  end
@@ -31,12 +31,15 @@ module Samagotchi
31
31
  # session (its name, or its URL) isn't attached again (#declined?)
32
32
  # until the user adds it again, or +force+: the user's own explicit
33
33
  # choice (a click), which clears the declined marker (by the name,
34
- # and by the URL).
34
+ # and by the URL). +movable+: whether a continue or a fork takes it
35
+ # to the new session (nil: the default, a command source moves;
36
+ # ContextSources::Source#movable?).
35
37
  # @return [String, nil] the source's name; nil when declined
36
38
  # @raise [Error]
37
- def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false)
39
+ def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false, movable: nil)
38
40
  own = own_location!
39
41
  source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
42
+ source = source.with(movable: movable ? true : false) unless movable.nil?
40
43
  if force
41
44
  own.undecline(source.name, hint: url && source.hint)
42
45
  elsif own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
@@ -117,6 +120,15 @@ module Samagotchi
117
120
  location&.candidates || []
118
121
  end
119
122
 
123
+ # What the continue that started this session left with the previous
124
+ # link (pushed sources, a peer's pairing among them): each one's name,
125
+ # hint (its last pushed one, else the source's) and the session it
126
+ # stayed in. Read-only; none for a session that isn't a continue's.
127
+ # @return [Array<ContextSources::Left>]
128
+ def left_behind
129
+ location&.left || []
130
+ end
131
+
120
132
  # Takes back this session's candidate +name+. A source attached from
121
133
  # it stays attached (chi context rm removes that).
122
134
  # @return [Boolean] whether there was one
@@ -271,6 +271,11 @@ module Samagotchi
271
271
  # one its participants read, not the owner's working session.
272
272
  def review? = !!saved_session&.review?
273
273
 
274
+ # The id of the session this one continues (a continue's new link,
275
+ # Session#continues), else nil.
276
+ # @return [String, nil]
277
+ def continues = saved_session&.continues
278
+
274
279
  # Whether the session is a fork (ctx.sessions.fork: /btw keep, a
275
280
  # plugin's): it has a parent and isn't a delegate child, and it
276
281
  # started from a conversation, so its first prompt isn't its first
@@ -40,6 +40,12 @@ module Samagotchi
40
40
  # quote. Such a text is titled by its first quoted line, not the quote's
41
41
  # `From your thinking:` label.
42
42
  #
43
+ # A continue leaves the pairing with the previous link (its inbox and
44
+ # outbox are pushed sources: ContextSources.move). The new link's quiet
45
+ # init task (#pair_again) posts the `moved` message to the inbox left
46
+ # behind, once, so the peer pairs again with the new id; its chi-peer
47
+ # skill knows how it paired.
48
+ #
43
49
  # A subclass (the bundle's Plugin) sets the strings, as constants:
44
50
  # INBOX, OUTBOX ("<peer>-inbox", "-outbox"), BUNDLE, NAME (the peer, as
45
51
  # the model and user call it: "Claude"), APP (its product: "Claude
@@ -62,6 +68,8 @@ module Samagotchi
62
68
  PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
63
69
  PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
64
70
  DIFF_LINES = 20
71
+ # Under the session's data dir: the moved message went (or was tried).
72
+ MOVED_MARKER = "moved-posted"
65
73
  # The label line a web quote starts with (annotations.js sourceLabel).
66
74
  QUOTE_LABEL = /\AFrom (?:your thinking|your earlier step|the .+ call|my earlier message):\z/
67
75
 
@@ -90,6 +98,7 @@ module Samagotchi
90
98
  run_command(args.to_s.strip, ctx)
91
99
  end
92
100
  chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate"
101
+ chi.init("Asking #{peer::NAME} to pair again", quiet: true) { |ctx| pair_again(ctx) }
93
102
  # The outbox source is the peer's answers: its own wake budget and
94
103
  # line (context.peer_turns).
95
104
  chi.peer_source peer::OUTBOX, label: peer::NAME
@@ -130,8 +139,52 @@ module Samagotchi
130
139
  "sent to #{self.class::NAME}: #{kind} · #{title}#{" (the text in #{where})" unless where == "inline"}"
131
140
  end
132
141
 
142
+ # A continue's new link whose previous link kept this peer's INBOX
143
+ # (ctx.context.left_behind): posts the moved message to that inbox's
144
+ # hint, once (a marker under the session's data dir: a worker restart
145
+ # doesn't post again). A post that fails is logged and dropped: no
146
+ # retry, and the peer's next push to the old id is refused with the
147
+ # new one (its skill pairs again then).
148
+ # @return [String] what it did (the quiet init task's summary)
149
+ def pair_again(ctx)
150
+ peer = self.class
151
+ previous = ctx.continues or return "not a continue"
152
+ id = ctx.session_id or return "no session yet"
153
+ left = ctx.context.left_behind.find { |row| row.name == peer::INBOX }
154
+ hint = left&.hint.to_s.strip
155
+ return "no #{peer::INBOX} left behind" if hint.empty?
156
+ return "asked already" unless claim_moved(ctx, id)
157
+
158
+ check_hint!(hint)
159
+ post(hint, moved_message(previous, id))
160
+ "asked #{peer::NAME} to pair again with #{id[0, 8]}"
161
+ rescue SendError, SystemCallError, ArgumentError => e
162
+ ctx.log.warn("moved_not_posted", error: e.message)
163
+ "couldn't ask #{peer::NAME} to pair again: #{e.message}"
164
+ end
165
+
166
+ # What the peer gets when +previous+ was continued as +id+: the usual
167
+ # header, kind moved, and how to go on. Not a literal pair command:
168
+ # the skill knows how its peer paired (a remote, an ssh socket).
169
+ def moved_message(previous, id)
170
+ short = previous.to_s[0, 8]
171
+ "chi #{short} · moved · continued as #{id}\n" \
172
+ "This chi session was continued as #{id}. Pair with #{id} the way you paired with #{short}, then " \
173
+ "answer there (chi context push #{self.class::OUTBOX} #{id})."
174
+ end
175
+
133
176
  private
134
177
 
178
+ # Takes the moved marker for session +id+; false when it is there.
179
+ def claim_moved(ctx, id)
180
+ File.open(File.join(session_dir(ctx, id), MOVED_MARKER), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
181
+ file.write("#{Time.now.utc.iso8601}\n")
182
+ end
183
+ true
184
+ rescue Errno::EEXIST
185
+ false
186
+ end
187
+
135
188
  # The transport: sends +text+ to the peer at +hint+ (the inbox's hint).
136
189
  # @raise [SendError]
137
190
  def post(_hint, _text) = raise(NotImplementedError, "#{self.class} must define #post")
@@ -7,6 +7,7 @@ require_relative "../worker_link"
7
7
  require_relative "../bridge/turn_accumulator"
8
8
  require_relative "../tools/delegate"
9
9
  require_relative "../children_status"
10
+ require_relative "../context_move"
10
11
  require_relative "../review_start"
11
12
  require_relative "../model_profile"
12
13
 
@@ -42,7 +43,8 @@ module Samagotchi
42
43
  # runs it as its first turn, and counts against session.max_children.
43
44
  # It keeps this session's own setup (SessionSetup: its llm_context values).
44
45
  # Each image a message names is copied into the child, or dropped with
45
- # a note in its message when its file is gone.
46
+ # a note in its message when its file is gone. This session's movable
47
+ # attached context goes too (ContextMove), with a note naming it.
46
48
  # @param messages [Array<Hash>] the child's conversation to start with
47
49
  # @param title [String, nil] what the lists show for it (the prompt, or
48
50
  # the first user message, by default)
@@ -60,11 +62,16 @@ module Samagotchi
60
62
  prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
61
63
  check_children(parent_id, state_dir) if prompt
62
64
 
65
+ # The fork takes this session's movable attached context, its
66
+ # subscriptions as its seed last had them.
67
+ before_spawn = lambda do |forked|
68
+ ContextMove.call(from: parent_id, to: forked.id, mode: :fork, seed: forked.messages, state_dir: state_dir)
69
+ end
63
70
  child = SessionManager.spawn_session(
64
71
  prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
65
72
  parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, preview: title,
66
73
  images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
67
- setup: @host.setup&.call || SessionSetup.new
74
+ setup: @host.setup&.call || SessionSetup.new, before_spawn: before_spawn
68
75
  )
69
76
  child.id
70
77
  rescue *REFUSED => e
@@ -571,7 +571,9 @@ module Samagotchi
571
571
  return "" unless session.continues
572
572
 
573
573
  moved = Array(session.moved_children).size
574
- " (continues #{session.continues[0, 8]}#{"; #{moved} delegate#{"s" if moved != 1} moved" if moved.positive?})"
574
+ context = session.moved_context&.words
575
+ " (continues #{session.continues[0, 8]}#{"; #{moved} delegate#{"s" if moved != 1} moved" if moved.positive?}" \
576
+ "#{"; #{context}" if context})"
575
577
  end
576
578
 
577
579
  # One existing session, then its next reply. The cursor and baseline
@@ -347,6 +347,10 @@ module Samagotchi
347
347
  # SessionManager.continue_session sets it); not saved.
348
348
  attr_accessor :moved_children
349
349
 
350
+ # The attached context a continue took to this new link
351
+ # (ContextSources::Moved; SessionContinue sets it); not saved.
352
+ attr_accessor :moved_context
353
+
350
354
  # The text web `continue →` prefills in this new link
351
355
  # (SessionChain.opener_for; SessionManager.continue_session sets it); not
352
356
  # saved.
@@ -78,6 +78,8 @@ module Samagotchi
78
78
  "context add --every" => SUPPORTED,
79
79
  "context add --why" => SUPPORTED,
80
80
  "context add --hint" => SUPPORTED,
81
+ "context add --movable" => SUPPORTED,
82
+ "context add --no-movable" => SUPPORTED,
81
83
  "context add --project" => LOCAL_ONLY,
82
84
  "context push" => SUPPORTED,
83
85
  "context push --message" => SUPPORTED,
@@ -12,6 +12,8 @@ require_relative "../note_delivery"
12
12
  require_relative "../image_store"
13
13
  require_relative "../context_candidates"
14
14
  require_relative "../context_sources"
15
+ require_relative "../archive_store"
16
+ require_relative "../session_chain"
15
17
  require_relative "../send_command/busy"
16
18
  require_relative "../chi_invocation"
17
19
  require_relative "../worker_sidecar"
@@ -285,17 +287,26 @@ module Samagotchi
285
287
  # project's when session_id is nil).
286
288
 
287
289
  # Attaches +source+ to the target's own sources.
290
+ # @return [ContextAdded]
288
291
  def context_add(target, source)
289
292
  location = context_location(target)
290
293
  location.add(source.with(scope: location.scope))
291
- nil
294
+ ContextAdded.new(name: source.name)
292
295
  end
293
296
 
294
- # New text for the target's --push source +name+.
297
+ # New text for the target's --push source +name+. Refused, nothing
298
+ # written, when the session was continued (its writer holds a stale
299
+ # id): a source of its own, or none of the name. A project's source
300
+ # takes it (the next link sees it too).
295
301
  # @param fetched [ContextSources::Fetched]
296
302
  # @return [ContextPush]
297
303
  def context_push(target, name, fetched)
298
- attached = context_attached(target, name) or return ContextPush.new(status: :missing)
304
+ attached = context_attached(target, name)
305
+ if !target.project? && (attached.nil? || attached.location.session?) &&
306
+ (next_id = continued_as(target.session_id))
307
+ return ContextPush.new(status: :continued, next_id: next_id)
308
+ end
309
+ return ContextPush.new(status: :missing) unless attached
299
310
  return ContextPush.new(status: :not_push) unless attached.source.push?
300
311
 
301
312
  before, written = attached.location.locked(name) do
@@ -491,6 +502,16 @@ module Samagotchi
491
502
  end
492
503
 
493
504
  def dir = @state_dir ? { state_dir: @state_dir } : {}
505
+
506
+ # The session that continues +id+, or nil. The archive check first: a
507
+ # continued link is always archived, and SessionChain.next_of loads
508
+ # every session.
509
+ def continued_as(id)
510
+ return nil unless ArchiveStore.archived?(session_dir(id))
511
+
512
+ SessionChain.next_of(id, state_dir: state_dir!)
513
+ end
514
+
494
515
  def state_dir! = @state_dir || Session.default_state_dir
495
516
  def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
496
517
 
@@ -181,7 +181,8 @@ module Samagotchi
181
181
  json = reply.json
182
182
  Created.new(id: json.fetch("id"), model_warning: json["model_warning"], continues: json["continues"],
183
183
  moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"],
184
- first_turn_id: json["first_turn_id"])
184
+ first_turn_id: json["first_turn_id"],
185
+ moved_context: ContextSources::Moved.from_h(json["moved_context"]))
185
186
  end
186
187
 
187
188
  # Whether a just-created session's worker came up within +timeout+
@@ -458,21 +459,23 @@ module Samagotchi
458
459
 
459
460
  # --- context (a session's own sources: a project's are local only)
460
461
 
462
+ # @return [ContextAdded]
461
463
  # @raise [ContextSources::Invalid] refused (a name taken, a bad one)
462
464
  def context_add(target, source)
463
- body = { name: source.name, why: source.why, hint: source.hint, added_by: source.added_by }
465
+ body = { name: source.name, why: source.why, hint: source.hint, added_by: source.added_by, movable: source.movable }
464
466
  source.push? ? body[:push] = true : body.merge!(cmd: source.cmd, every: source.every_seconds)
465
- context_added(request(:post, "#{session_path(target.session_id)}/context", body: body.compact))
466
- nil
467
+ reply = request(:post, "#{session_path(target.session_id)}/context", body: body.compact)
468
+ context_added(reply)
469
+ ContextAdded.new(name: source.name, movable_dropped: movable_dropped?(reply, source.movable))
467
470
  end
468
471
 
469
472
  # A URL through the remote's installed bundles' providers (chi web's
470
473
  # {url} body: the page's own).
471
- # @return [String] the source's name
474
+ # @return [ContextAdded] the source's name
472
475
  # @raise [ContextProviders::Invalid] no bundle there resolves it
473
- def context_add_url(target, url, why:, hint:, added_by:)
476
+ def context_add_url(target, url, why:, hint:, added_by:, movable: nil)
474
477
  reply = request(:post, "#{session_path(target.session_id)}/context",
475
- body: { url: url, why: why, hint: hint, added_by: added_by }.compact)
478
+ body: { url: url, why: why, hint: hint, added_by: added_by, movable: movable }.compact)
476
479
  if reply.status == 422 && no_provider?(reply)
477
480
  require_relative "../context_providers"
478
481
  detail = "no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
@@ -482,9 +485,13 @@ module Samagotchi
482
485
  raise ContextProviders::Invalid, "#{reply.json["detail"]} (on #{name})"
483
486
  end
484
487
  context_added(reply)
485
- reply.json.fetch("name")
488
+ ContextAdded.new(name: reply.json.fetch("name"), movable_dropped: movable_dropped?(reply, movable))
486
489
  end
487
490
 
491
+ # A movable flag sent that the 201 doesn't echo: an older chi web
492
+ # dropped it.
493
+ def movable_dropped?(reply, movable) = !movable.nil? && !reply.json.key?("movable")
494
+
488
495
  # @param fetched [ContextSources::Fetched]
489
496
  # @return [ContextPush]
490
497
  def context_push(target, name, fetched)
@@ -498,6 +505,10 @@ module Samagotchi
498
505
 
499
506
  raise_refusal(reply)
500
507
  when 409 then ContextPush.new(status: :not_push)
508
+ when 410
509
+ raise_refusal(reply) unless reply.error == "context_continued"
510
+
511
+ ContextPush.new(status: :continued, next_id: reply.json["next_id"].to_s)
501
512
  when 400 then raise ContextSources::Invalid, reply.json["detail"].to_s
502
513
  else raise_refusal(reply)
503
514
  end