samagotchi 0.159.0 → 0.161.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -6,19 +6,41 @@ module Samagotchi
6
6
  # A project memory folder left behind by a move: memories copied to a
7
7
  # machine (or a path) where the project root differs, so its key
8
8
  # (MemoryPaths.project_key: basename + MD5 of the root) differs too, and
9
- # chi finds no project memories without a word about why.
9
+ # chi finds no project memories without a word about why. A marked
10
+ # project with an id is keyed by the id (`<basename>-<8 hex>`, which the
11
+ # pattern never takes for a path key): it is asked about only while its
12
+ # id folder holds no *.md, like any other (memories moved before it had
13
+ # an id). An id folder is a candidate itself for a project keyed by path
14
+ # with the same basename: a marked folder that `git init` made a
15
+ # repository.
10
16
  #
11
17
  # Pure: one listing of the projects dir, no git subprocess. Said at
12
18
  # session start (Engine#announce_load_events!, label "memory") and under a
13
19
  # memory_read miss (Tools::MemoryRead); never in the system prompt.
14
20
  module MemoryOrphans
15
21
  # @!attribute computed [String] the folder chi reads (this project's key)
16
- # @!attribute candidates [Array<String>] same-basename folders, other hashes
22
+ # @!attribute candidates [Array<String>] same-basename folders keyed by
23
+ # another path's hash or by a project id
17
24
  # @!attribute computed_exists [Boolean] whether +computed+ is a folder (empty of memories)
18
25
  Found = Data.define(:computed, :candidates, :computed_exists)
19
26
 
27
+ # A project keyed by its path is <name>_<8 hex>; a marked project's folder is
28
+ # its id, <name>-<8 hex>.
29
+ PATH_HASH = /_\h{8}\z/
30
+
20
31
  module_function
21
32
 
33
+ # A candidate folder's key, in words: an earlier path's hash, or a chi project
34
+ # id (a folder marked by `chi project init`, left behind when `git init` makes
35
+ # it a repository). +candidate+ is a folder path.
36
+ def key_note(candidate)
37
+ hash_keyed?(candidate) ? "another path's hash" : "project id #{File.basename(candidate)}"
38
+ end
39
+
40
+ # Internal: is this candidate keyed by its path rather than by a project id?
41
+ def hash_keyed?(candidate) = File.basename(candidate).match?(PATH_HASH)
42
+ private_class_method :hash_keyed?
43
+
22
44
  # The orphan candidates for +cwd+'s project, or nil: only inside a
23
45
  # project, a git repository or a marked folder (outside both the root is
24
46
  # cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
@@ -33,10 +55,14 @@ module Samagotchi
33
55
  return nil if File.directory?(computed) && Dir.children(computed).any? { |name| name.end_with?(".md") }
34
56
 
35
57
  key = File.basename(computed)
36
- # The root's basename, never a split of a key on "_" (a basename may hold one).
37
- pattern = /\A#{Regexp.escape(File.basename(MemoryPaths.project_root(cwd)))}_\h{8}\z/
58
+ # The root's basename, never a split of a key on "_" (a basename may hold one): another path's key
59
+ # (<basename>_<8 hex>), or a marked project's id folder (<basename>-<8 hex>, ProjectId) a `git init`
60
+ # in the marked folder (git wins: keyed by its path again) left behind.
61
+ pattern = /\A#{Regexp.escape(File.basename(MemoryPaths.project_root(cwd)))}[_-]\h{8}\z/
38
62
  names = Dir.children(projects).select { |name| name != key && name.match?(pattern) }
39
- candidates = names.sort.map { |name| File.join(projects, name) }.select { |path| File.directory?(path) }
63
+ # A link is an adopted project's old name (ProjectInit.adopt), never an orphan.
64
+ candidates = names.sort.map { |name| File.join(projects, name) }
65
+ .select { |path| File.directory?(path) && !File.symlink?(path) }
40
66
  return nil if candidates.empty?
41
67
 
42
68
  Found.new(computed: computed, candidates: candidates, computed_exists: File.directory?(computed))
@@ -57,9 +83,18 @@ module Samagotchi
57
83
  found = find(env: env, cwd: cwd)
58
84
  return unless found
59
85
 
60
- name = File.basename(found.computed).sub(/_\h{8}\z/, "")
61
- "a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
62
- "memories belong to this project before using them."
86
+ # The project's name is its root folder's basename: splitting the key would
87
+ # print a project id as the name when this project is keyed by one.
88
+ name = File.basename(MemoryPaths.project_root(cwd))
89
+ if found.candidates.size == 1
90
+ candidate = found.candidates.first
91
+ "a folder with the same project name (#{name}) exists under #{key_note(candidate)}; ask the user " \
92
+ "whether its memories belong to this project before using them."
93
+ else
94
+ list = found.candidates.map { |candidate| "#{candidate} (#{key_note(candidate)})" }.join(", ")
95
+ "folders with the same project name (#{name}) exist under other keys (#{list}); ask the user whether their " \
96
+ "memories belong to this project before using them."
97
+ end
63
98
  end
64
99
 
65
100
  def text(found)
@@ -72,13 +107,19 @@ module Samagotchi
72
107
  else
73
108
  "`mv #{candidate} #{computed}`"
74
109
  end
75
- "#{lead}, but #{candidate} has the same project name with another path's hash (memories moved from " \
76
- "another machine or path?). A name match can't prove it's this project; if it is, #{fix}."
110
+ why = if hash_keyed?(candidate)
111
+ "has the same project name with #{key_note(candidate)} (memories moved from another machine or " \
112
+ "path?)"
113
+ else
114
+ "has the same project name keyed by a project id (#{File.basename(candidate)}): the folder " \
115
+ "chi project init marked, keyed by its id instead of its path"
116
+ end
117
+ "#{lead}, but #{candidate} #{why}. A name match can't prove it's this project; if it is, #{fix}."
77
118
  else
78
119
  fix = found.computed_exists ? "move its files into #{computed}" : "`mv <that folder> #{computed}`"
79
- "#{lead}, but these have the same project name with another path's hash (memories moved from another " \
80
- "machine or path?): #{found.candidates.join(", ")}. A name match can't prove one is this project; if one " \
81
- "is, #{fix}."
120
+ list = found.candidates.map { |candidate| "#{candidate} (#{key_note(candidate)})" }.join(", ")
121
+ "#{lead}, but these have the same project name (memories moved from another machine or path?): " \
122
+ "#{list}. A name match can't prove one is this project; if one is, #{fix}."
82
123
  end
83
124
  end
84
125
  end
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require_relative "config"
5
5
  require_relative "guardrails/path_resolve"
6
+ require_relative "project_id"
6
7
 
7
8
  module Samagotchi
8
9
  # Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
@@ -23,8 +24,12 @@ module Samagotchi
23
24
  # :none; +root+ is what memories are keyed on (#project_root: git's
24
25
  # common-dir checkout, the marked folder's realpath, or the folder
25
26
  # itself); +top+ is the folder a session reads its AGENTS.md chain from
26
- # (the work tree's top, the marked root; nil for :none).
27
- ProjectLookup = Data.define(:kind, :root, :top) do
27
+ # (the work tree's top, the marked root; nil for :none); +id+ is the
28
+ # marker's id (ProjectId), set only for kind :marker with a valid one
29
+ # (a git repository, and a marker #lookup ignores, never give one).
30
+ ProjectLookup = Data.define(:kind, :root, :top, :id) do
31
+ def initialize(kind:, root:, top:, id: nil) = super
32
+
28
33
  def project? = kind != :none
29
34
  def git? = kind == :git
30
35
  def marker? = kind == :marker
@@ -47,7 +52,7 @@ module Samagotchi
47
52
  return ProjectLookup.new(kind: :git, root: git_root(dot_git), top: File.dirname(dot_git)) if dot_git
48
53
 
49
54
  marked = nearest_marker(cwd, home: home)
50
- return ProjectLookup.new(kind: :marker, root: marked, top: marked) if marked
55
+ return ProjectLookup.new(kind: :marker, root: marked, top: marked, id: marker_id(marked)) if marked
51
56
 
52
57
  ProjectLookup.new(kind: :none, root: cwd, top: nil)
53
58
  end
@@ -73,12 +78,30 @@ module Samagotchi
73
78
  File.join(system_dir(env: env), "projects")
74
79
  end
75
80
 
76
- # One folder per project root: basename + 8 hex chars of MD5(full root path).
77
- def project_key(cwd = Dir.pwd) = key_for_root(project_root(cwd))
81
+ # One folder per project: a marked project's id (ProjectId), else its
82
+ # root's path key (#key_for_root).
83
+ def project_key(cwd = Dir.pwd)
84
+ found = lookup(cwd)
85
+ key_for(root: found.root, id: found.id)
86
+ end
87
+
88
+ # The key of a project already resolved: +id+ (a ProjectLookup's, a
89
+ # session's project_id) when there is one, else +root+'s path key. Every
90
+ # caller holding a root keys here, with the id that came with it.
91
+ def key_for(root:, id:) = id || key_for_root(root)
78
92
 
79
- # #project_key for a root already resolved (a session's project_root).
93
+ # The path key: basename + 8 hex chars of MD5(full root path). Pure: it
94
+ # reads no file (#key_for adds the id).
80
95
  def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
81
96
 
97
+ # The id in the marker at +root+, or nil (none, or not a valid one: one
98
+ # debug line says why).
99
+ def marker_id(root)
100
+ read = ProjectId.read(root)
101
+ ProjectId.log_problem(root, read)
102
+ read.id
103
+ end
104
+
82
105
  # The project root (#lookup): in a repository, its common git directory
83
106
  # identifies the project; every linked worktree shares it. Returns the
84
107
  # checkout holding it when it is named .git, otherwise the git dir itself
@@ -124,7 +124,11 @@ module Samagotchi
124
124
  # The project the report is for, as MemoryPaths keys it (a worktree
125
125
  # resolves to its repository's root; both spellings of a path, /var and
126
126
  # /private/var, mean the same project).
127
- @root = expand(MemoryPaths.project_root(File.expand_path(root.to_s)))
127
+ found = MemoryPaths.lookup(File.expand_path(root.to_s))
128
+ @root = expand(found.root)
129
+ # A marked project's id: its sessions from before a move (another
130
+ # stored root, the same id) are its sessions too.
131
+ @project_id = found.id
128
132
  @env = env
129
133
  @chars_per_token = chars_per_token.positive? ? chars_per_token.to_f : 4.0
130
134
  @warn_limit = warn_limit.to_i
@@ -331,23 +335,30 @@ module Samagotchi
331
335
  File.expand_path(dir)
332
336
  end
333
337
 
334
- # The project a session belongs to: the root its file stores, or (a file
335
- # from before the field, or a folder in no repository) the root of the
336
- # folder it works in.
337
- def session_project_root(session)
338
- root = session.project_root ||
339
- (session.working_directory.to_s.empty? ? nil : MemoryPaths.project_root(File.expand_path(session.working_directory.to_s)))
340
- root && expand(root)
338
+ # The project a session belongs to, [root, id]: the root (and id) its
339
+ # file stores, or (a file from before the field, or a folder in no
340
+ # repository) the lookup of the folder it works in; [nil, nil] for none.
341
+ def session_project(session)
342
+ if session.project_root
343
+ [expand(session.project_root), session.project_id]
344
+ elsif !session.working_directory.to_s.empty?
345
+ found = MemoryPaths.lookup(File.expand_path(session.working_directory.to_s))
346
+ [expand(found.root), found.id]
347
+ else
348
+ [nil, nil]
349
+ end
341
350
  end
342
351
 
343
352
  def project_dir_of(session)
344
- root = session_project_root(session)
345
- root ? File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for_root(root)) : nil
353
+ root, id = session_project(session)
354
+ root ? File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for(root: root, id: id)) : nil
346
355
  end
347
356
 
348
- # A session of this folder's project (worktrees of one repository share it).
357
+ # A session of this folder's project (worktrees of one repository share
358
+ # it; a marked project's sessions from before a move share its id).
349
359
  def project_session?(session)
350
- session_project_root(session) == @root
360
+ root, id = session_project(session)
361
+ root == @root || (!@project_id.nil? && id == @project_id)
351
362
  end
352
363
 
353
364
  # "system/delegated" is the memory named delegated.
@@ -20,6 +20,7 @@ module Samagotchi
20
20
  Usage: chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
21
21
  Adds TEXT (or stdin) to each session as a context note: background
22
22
  the model sees on its next turn, not a prompt. It starts no turn.
23
+ A session that was continued: the newest link of its chain gets it.
23
24
  --source NAME where it came from, shown to the model (default: cli)
24
25
  -m TEXT the note; without it, stdin is read
25
26
  --all every session a worker runs now, in every project
@@ -116,10 +117,17 @@ module Samagotchi
116
117
  # @return [Boolean] whether the note was queued
117
118
  def deliver(id, text, source)
118
119
  result = @access.note(id, text: text, source: source)
119
- @stdout.puts("#{id[0, 8]} #{outcome(result)}")
120
+ @stdout.puts("#{where(id, result)} #{outcome(result)}")
120
121
  result.delivered?
121
122
  end
122
123
 
124
+ # The id, or for a session that was continued the link that took it.
125
+ def where(id, result)
126
+ return id[0, 8] unless result.continued?
127
+
128
+ "#{id[0, 8]} → #{result.id[0, 8]} (continued)"
129
+ end
130
+
123
131
  def outcome(result)
124
132
  case result.status
125
133
  when :refused then "refused: #{result.refusal}"
@@ -5,31 +5,35 @@ require_relative "owner_lock"
5
5
  require_relative "session_inbox"
6
6
 
7
7
  module Samagotchi
8
- # Loaded on first use: this autoload keeps SessionManager and its ~80
9
- # files unloaded until the delegate/session path needs them.
10
- autoload :SessionManager, File.expand_path("session_manager", __dir__)
11
-
12
8
  # The one rule for putting a context note into a session, shared by
13
- # `chi note`, the agent's send_note and `chi broadcast`: a session a
14
- # one-turn chi run owns, or a chi scratch session, refuses it; any other
15
- # gets the note in its notes/ folder, which a live worker adds within
16
- # seconds and a session with no worker at its next start. Callers word
17
- # the outcome (#refusal for a refused one).
9
+ # `chi note`, the agent's send_note, `chi broadcast` and /objectives: a
10
+ # note delivery (SessionInbox.deliver :note). A note to a link that was
11
+ # continued goes to its chain's newest link (SessionInbox::Head); a
12
+ # session a one-turn chi run owns, or a chi scratch session, refuses it;
13
+ # any other gets the note in its notes/ folder, which a live worker adds
14
+ # within seconds and a session with no worker at its next start (a note
15
+ # never starts one). Callers word the outcome (#refusal for a refused one).
18
16
  module NoteDelivery
19
17
  # Why a one-turn chi run's session takes no note.
20
18
  ONE_SHOT_REFUSAL = "it is #{OwnerLock::TUI_BUSY}; send the note once it ends"
21
19
 
22
20
  # The outcome for one session.
23
- # @!attribute status [Symbol] :queued (a worker runs it), :waits (no
24
- # worker: it waits for the next start) or :refused (a one-turn chi run
25
- # owns it, or it is a chi scratch session: +scratch+)
21
+ # @!attribute id [String] the session that has the note: +asked+, or
22
+ # the newest link of its chain
23
+ # @!attribute status [Symbol] :queued (a worker runs it or is starting),
24
+ # :waits (no worker: it waits for the next start) or :refused (a
25
+ # one-turn chi run owns it, or it is a chi scratch session: +scratch+)
26
26
  # @!attribute queued [Integer] notes waiting in its notes/ folder
27
27
  # (:waits only; 0 otherwise)
28
- Result = Data.define(:id, :status, :queued, :scratch) do
29
- def initialize(id:, status:, queued:, scratch: false) = super
28
+ # @!attribute asked [String] the id the caller named (+id+ by default)
29
+ Result = Data.define(:id, :status, :queued, :scratch, :asked) do
30
+ def initialize(id:, status:, queued:, scratch: false, asked: nil) = super(id:, status:, queued:, scratch:, asked: asked || id)
30
31
 
31
32
  def delivered? = status != :refused
32
33
 
34
+ # The note went on to a later link of +asked+'s chain.
35
+ def continued? = asked != id
36
+
33
37
  # Why a refused note was refused, in its condition's one wording.
34
38
  def refusal = scratch ? Session::SCRATCH_REFUSED : ONE_SHOT_REFUSAL
35
39
  end
@@ -38,19 +42,25 @@ module Samagotchi
38
42
 
39
43
  # @param id [String] a full session id (resolved by the caller)
40
44
  # @param objectives [Hash, nil] an objectives note's payload
45
+ # @param pin [Boolean] keep it in +id+, never its chain's newest link
46
+ # (an /objectives list is the conversation of +id+'s own)
47
+ # @param resolved [SessionInbox::Resolved, nil] the link the caller
48
+ # resolved already (SessionInbox::Head.resolve(:note, id)): no second walk
41
49
  # @return [Result]
42
50
  # @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
43
- def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil)
44
- owner = SessionManager.session_owner(id, state_dir: state_dir)
45
- return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
46
- # Its terminal's alone, live or left behind.
47
- return Result.new(id: id, status: :refused, queued: 0, scratch: true) if Session.load(id, state_dir: state_dir).scratch
48
-
49
- path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
50
- objectives: objectives, state_dir: state_dir)
51
- return Result.new(id: id, status: :queued, queued: 0) if owner
51
+ # @raise [Session::NotFound] no such session
52
+ def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil, pin: false,
53
+ resolved: nil)
54
+ body = SessionInbox.checked_text(text)
55
+ delivered = SessionInbox.deliver(:note, id, state_dir: state_dir, pin: pin, resolved: resolved) do |at|
56
+ SessionInbox.write_note(at, text: body, source: source, from_session: from_session, from_cwd: from_cwd,
57
+ objectives: objectives, state_dir: state_dir)
58
+ false
59
+ end
60
+ raise Session::NotFound, id if delivered.wake == :gone
52
61
 
53
- Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)
62
+ Result.new(id: delivered.id, asked: delivered.asked, status: delivered.status, queued: delivered.queued || 0,
63
+ scratch: delivered.refused? && delivered.wake == :scratch)
54
64
  end
55
65
  end
56
66
  end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "archive_store"
7
+ require_relative "context_move"
8
+ require_relative "log"
9
+ require_relative "objectives"
10
+ require_relative "session"
11
+ require_relative "session_chain"
12
+ require_relative "session_inbox"
13
+
14
+ module Samagotchi
15
+ # A session chain's notes/ across a continue. The continue moves the
16
+ # previous link's waiting notes to the new link (.move, the last step
17
+ # of its before_spawn); a continue that fails gives them back (.give_back);
18
+ # the new link's worker adopts a note written to the previous link after
19
+ # the move (.adoption: a writer that resolved the id just before the new
20
+ # link was saved, or an older chi that knows no chain head). A note keeps
21
+ # its file name everywhere: its id, and its place in arrival order.
22
+ #
23
+ # Objectives lists stay where they are: an /objectives note is its own
24
+ # session's (pinned), and the continue's Objectives.carry already took the
25
+ # previous link's newest one into the new link's list.
26
+ module NoteMove
27
+ # The notes a continue writes into its new link itself (the chain note,
28
+ # ChildMove's, the attached context's, the objectives' carry): a failed
29
+ # continue deletes them with the link.
30
+ CONTINUE_SOURCES = [SessionChain::NOTE_SOURCE, ContextMove::NOTE_SOURCE, Objectives::SOURCE].freeze
31
+
32
+ module_function
33
+
34
+ # Moves +from+'s notes into +to+'s notes/, but not its objectives lists
35
+ # nor the notes a continue into +from+ wrote there (CONTINUE_SOURCES: a
36
+ # link continued before it ever started holds its own chain note, which
37
+ # the new link's own replaces). The rename is the claim: one a worker or
38
+ # another move took meanwhile is skipped. A claimed one
39
+ # (*.json.processing: an old worker took it and never absorbed it, or
40
+ # absorbed it and died before the delete) moves too, un-claimed, logged
41
+ # (notes_moved_claimed): the link is archived, so left there it would be
42
+ # lost, and a note seen twice beats a lost one.
43
+ # @return [Array<String>] the file names moved (un-claimed), oldest first
44
+ def move(from:, to:, state_dir:)
45
+ files = Dir.glob(File.join(notes_dir(from, state_dir), "*.{json,json.processing}"))
46
+ .sort_by { |file| unclaimed_name(file) }
47
+ .select { |file| (data = read(file)) && !objectives?(data) && !CONTINUE_SOURCES.include?(data["source"]) }
48
+ moved = rename(files, notes_dir(to, state_dir))
49
+ claimed = files.count { |file| file.end_with?(".processing") && moved.include?(unclaimed_name(file)) }
50
+ Log.info(:worker, "notes_moved_claimed", from: from[0, 8], to: to[0, 8], count: claimed) if claimed.positive?
51
+ Log.info(:worker, "notes_moved", from: from[0, 8], to: to[0, 8], count: moved.size) unless moved.empty?
52
+ moved
53
+ end
54
+
55
+ # A failed continue's undo, before its link +from+ is deleted: back to
56
+ # +to+ go the notes it moved (+moved+) and any other sent to the link
57
+ # meanwhile, never one the continue wrote itself (CONTINUE_SOURCES).
58
+ # @return [Array<String>] the file names given back
59
+ def give_back(from:, to:, moved:, state_dir:)
60
+ files = unclaimed(notes_dir(from, state_dir)).select do |file|
61
+ next true if moved.include?(File.basename(file))
62
+
63
+ data = read(file)
64
+ data && !objectives?(data) && !CONTINUE_SOURCES.include?(data["source"])
65
+ end
66
+ rename(files, notes_dir(to, state_dir)).tap do |back|
67
+ Log.info(:worker, "notes_given_back", from: from[0, 8], to: to[0, 8], count: back.size) unless back.empty?
68
+ end
69
+ end
70
+
71
+ # How far back along the chain a worker adopts (Adoption): the links
72
+ # continued into it, newest first.
73
+ ADOPT_MAX_LINKS = 8
74
+
75
+ # What a worker for +session+ adopts from the links before it, nil when
76
+ # there is nothing to adopt from: a first link, a predecessor not
77
+ # archived (checked first: no listing then), or one whose next link
78
+ # (SessionChain.next_of) is another. Else one listing walks back: each
79
+ # archived link whose next link is the one after it, from the newest, up
80
+ # to ADOPT_MAX_LINKS, stopping at one not archived or gone; each is
81
+ # adopted from by the creation of the link that continued it.
82
+ # @param session [Session]
83
+ # @return [Adoption, nil]
84
+ def adoption(session, state_dir:)
85
+ from = session.continues
86
+ return nil unless Session.valid_id?(from) && archived?(from, state_dir)
87
+
88
+ sessions = Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
89
+ byid = sessions.to_h { |s| [s.id, s] }
90
+ nexts = sessions.each_with_object({}) { |s, map| map[s.continues] ||= s.id if s.continues }
91
+ sources = []
92
+ child = session
93
+ while Session.valid_id?(from) && sources.size < ADOPT_MAX_LINKS && sources.none? { |src| src.from == from }
94
+ since = time_of(child.created_at)
95
+ break unless since && nexts[from] == child.id && archived?(from, state_dir)
96
+
97
+ sources << Source.new(from: from, since: since.to_i)
98
+ child = byid[from] or break
99
+ from = child.continues
100
+ end
101
+ sources.empty? ? nil : Adoption.new(to: session.id, sources: sources, state_dir: state_dir)
102
+ end
103
+
104
+ # A link adopted from (#adoption) and from when (whole seconds: the
105
+ # creation of the link that continued it).
106
+ Source = Data.define(:from, :since)
107
+
108
+ # A worker's adoption of the notes the links before it get after their
109
+ # continue: #call on each pass takes them while each link is archived
110
+ # (back in the lists, its notes are its own, and the walk stops there).
111
+ # Only notes from the second the next link was made on (a note's stamp
112
+ # has whole seconds): older ones are from before the continue (a
113
+ # continue made before notes moved left them), and would come days late,
114
+ # out of order with the conversation. They, objectives lists and claimed
115
+ # ones stay, each logged once.
116
+ class Adoption
117
+ def initialize(to:, sources:, state_dir:)
118
+ @to = to
119
+ @sources = sources
120
+ @state_dir = state_dir
121
+ @to_dir = NoteMove.notes_dir(to, state_dir)
122
+ @left = Set.new
123
+ end
124
+
125
+ # @return [Array<String>] the file names adopted
126
+ def call
127
+ adopted = []
128
+ @sources.each do |source|
129
+ break unless NoteMove.archived?(source.from, @state_dir)
130
+
131
+ adopted.concat(adopt(source))
132
+ end
133
+ adopted
134
+ end
135
+
136
+ private
137
+
138
+ def adopt(source)
139
+ files = NoteMove.unclaimed(NoteMove.notes_dir(source.from, @state_dir)).reject { |file| @left.include?(file) }
140
+ return [] if files.empty?
141
+
142
+ take, leave = files.partition { |file| adoptable?(NoteMove.read(file), source.since) }
143
+ log_left(source, leave)
144
+ NoteMove.rename(take, @to_dir).tap do |adopted|
145
+ Log.info(:worker, "notes_adopted", from: source.from[0, 8], sid: @to[0, 8], count: adopted.size) unless adopted.empty?
146
+ end
147
+ end
148
+
149
+ def adoptable?(data, since)
150
+ return false if data.nil? || NoteMove.objectives?(data)
151
+
152
+ (time = NoteMove.time_of(data["created_at"])) && time.to_i >= since
153
+ end
154
+
155
+ def log_left(source, files)
156
+ return if files.empty?
157
+
158
+ @left.merge(files)
159
+ Log.info(:worker, "notes_left_old", from: source.from[0, 8], sid: @to[0, 8], count: files.size)
160
+ end
161
+ end
162
+
163
+ def archived?(id, state_dir) = ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
164
+
165
+ # @return [Time, nil]
166
+ def time_of(value)
167
+ Time.iso8601(value.to_s)
168
+ rescue ArgumentError
169
+ nil
170
+ end
171
+
172
+ def notes_dir(id, state_dir) = File.join(Session.session_dir(id, state_dir: state_dir), SessionInbox::NOTES_DIR)
173
+
174
+ # The unclaimed note files, oldest first.
175
+ def unclaimed(dir) = Dir.glob(File.join(dir, "*.json")).sort_by { |file| File.basename(file) }
176
+
177
+ # @return [Hash, nil] the note file's JSON, nil when it can't be read
178
+ def read(file)
179
+ data = JSON.parse(File.read(file))
180
+ data.is_a?(Hash) ? data : nil
181
+ rescue JSON::ParserError, SystemCallError
182
+ nil
183
+ end
184
+
185
+ def objectives?(data) = data.is_a?(Hash) && (data["objectives"].is_a?(Hash) || data["source"] == Objectives::SOURCE)
186
+
187
+ # A note file's name without a claim's ".processing".
188
+ def unclaimed_name(file) = File.basename(file).delete_suffix(".processing")
189
+
190
+ # @return [Array<String>] the names renamed into +dir+, un-claimed (one
191
+ # taken meanwhile is skipped)
192
+ def rename(files, dir)
193
+ return [] if files.empty?
194
+
195
+ FileUtils.mkdir_p(dir)
196
+ files.filter_map do |file|
197
+ name = unclaimed_name(file)
198
+ File.rename(file, File.join(dir, name))
199
+ name
200
+ rescue Errno::ENOENT
201
+ nil
202
+ end
203
+ end
204
+ end
205
+ end
@@ -97,8 +97,10 @@ module Samagotchi
97
97
  end
98
98
 
99
99
  def deliver(list, session)
100
+ # Pinned: the list is this session's own, never its chain's newest link's.
100
101
  result = NoteDelivery.deliver(session.id, text: list.text, source: Objectives::SOURCE,
101
- state_dir: @engine.session_state_dir, objectives: list.to_payload)
102
+ state_dir: @engine.session_state_dir, objectives: list.to_payload,
103
+ pin: true)
102
104
  return ["refused: #{result.refusal}", :error] unless result.delivered?
103
105
 
104
106
  @engine.announce_objectives(session)
@@ -23,6 +23,8 @@ module Samagotchi
23
23
  # web's continueRefusal reads "<short> (why)" out of a refusal.
24
24
  TUI_BUSY = "in use by a one-turn chi --non-interactive run or an older chi's terminal"
25
25
  DEFAULT_WAIT = 2.0
26
+ # The kind a deleter holds the lock as while a session's files go.
27
+ DELETING = "deleting"
26
28
  RETRY_INTERVAL = 0.05
27
29
  OWNER_READ_ATTEMPTS = 10
28
30
  # Seconds .await_release waits past a leaving worker's own budget: its
@@ -33,7 +35,7 @@ module Samagotchi
33
35
 
34
36
  # Who holds the lock, as .owner reads it from the lock file. Its fields
35
37
  # may be nil for a moment right after acquisition.
36
- # kind: "worker" or "tui"; pid as written (an Integer). leaving_at (an
38
+ # kind: "worker", "tui" or "deleting" (Owner#deleting?); pid as written (an Integer). leaving_at (an
37
39
  # ISO 8601 time) and leave_wait (seconds): set once a worker is leaving
38
40
  # (#mark_leaving), its Bridge closed, the lock held for its last work
39
41
  # (the recap on leave) for up to leave_wait.
@@ -45,6 +47,10 @@ module Samagotchi
45
47
 
46
48
  def worker? = kind == "worker"
47
49
 
50
+ # A deleter removing the session's files (SessionManager's
51
+ # remove_session_files): no worker, nothing to signal.
52
+ def deleting? = kind == DELETING
53
+
48
54
  # A worker that closed its Bridge and releases the lock once its
49
55
  # last work is done.
50
56
  def leaving? = worker? && !leaving_at.nil?
@@ -152,7 +152,8 @@ module Samagotchi
152
152
  return [] unless id && Session.exist?(id, state_dir: state_dir)
153
153
 
154
154
  session = Session.load(id, state_dir: state_dir)
155
- ContextSources.attached(id, project_root: session.project_root, state_dir: state_dir).map do |attached|
155
+ ContextSources.attached(id, project_root: session.project_root, project_id: session.project_id,
156
+ state_dir: state_dir).map do |attached|
156
157
  snapshot = attached.snapshot
157
158
  { name: attached.name, scope: attached.location.scope, why: attached.source.why,
158
159
  hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
@@ -192,6 +193,7 @@ module Samagotchi
192
193
 
193
194
  def attached?(candidate, session)
194
195
  ContextSources.attached(session&.id || @host.session_id.call, project_root: session&.project_root,
196
+ project_id: session&.project_id,
195
197
  state_dir: state_dir, shadowed: true)
196
198
  .any? { |a| a.name == candidate.name || (candidate.url && a.source.hint == candidate.url) }
197
199
  end
@@ -71,7 +71,10 @@ module Samagotchi
71
71
  prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
72
72
  parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, preview: title,
73
73
  images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
74
- setup: @host.setup&.call || SessionSetup.new, before_spawn: before_spawn
74
+ setup: @host.setup&.call || SessionSetup.new, before_spawn: before_spawn,
75
+ # The first prompt is the plugin's, as #send's is: no command of the
76
+ # owner's (CommandIntake), a ! line refused.
77
+ client_id: ClientId::PLUGIN
75
78
  )
76
79
  child.id
77
80
  rescue *REFUSED => e