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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -1
- data/docs/architecture.md +2 -2
- data/docs/context.md +66 -4
- data/docs/internals/remote-sessions.md +4 -1
- data/docs/plugins.md +40 -1
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_command.rb +32 -9
- data/lib/samagotchi/context_move.rb +31 -0
- data/lib/samagotchi/context_sources.rb +248 -10
- data/lib/samagotchi/plugin/attached_context.rb +14 -2
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/plugin/peer.rb +53 -0
- data/lib/samagotchi/plugin/sessions.rb +9 -2
- data/lib/samagotchi/send_command.rb +3 -1
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/contract.rb +2 -0
- data/lib/samagotchi/session_access/local.rb +24 -3
- data/lib/samagotchi/session_access/remote.rb +19 -8
- data/lib/samagotchi/session_access.rb +26 -6
- data/lib/samagotchi/session_continue.rb +9 -2
- data/lib/samagotchi/testing/boundary.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -8
- data/lib/samagotchi/web/public/chains.js +10 -2
- data/lib/samagotchi/web/session_access_routes.rb +7 -2
- data/lib/samagotchi/web/session_create.rb +1 -0
- metadata +2 -1
|
@@ -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
|
|
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
|
-
|
|
57
|
-
|
|
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)
|
|
195
|
+
next unless file.end_with?(".json") && !file.end_with?(SNAPSHOT_SUFFIX)
|
|
183
196
|
|
|
184
197
|
name = file.delete_suffix(".json")
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
466
|
-
|
|
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 [
|
|
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
|