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
@@ -19,33 +19,40 @@ module Samagotchi
19
19
  include CLI::Command
20
20
 
21
21
  USAGE = <<~TEXT
22
- Usage: chi project [init [DIR]]
22
+ Usage: chi project [init [--new-id] [DIR]]
23
23
  (none) this folder's project: how it was found (git, marker or none), its root, its
24
- top and its project memories folder
24
+ top, its id and its project memories folder
25
25
  init [DIR] make DIR (default: this folder; created when missing) a chi project: writes
26
- DIR/#{MemoryPaths::MARKER}, so DIR and every subfolder share one project memories
27
- folder and group their sessions. Not in a git repository (it already is the
28
- project), nor $HOME or a folder above it
26
+ DIR/#{MemoryPaths::MARKER} with an id, so DIR and every subfolder share one project
27
+ memories folder (kept when DIR moves) and group their sessions. Not in a git
28
+ repository (it already is the project), nor $HOME or a folder above it. On a
29
+ marked folder with no id: adopts one, moving its folders to it
30
+ --new-id a marked folder's own new id (a copied folder going its own way): its
31
+ memories and project context copied to it
29
32
  TEXT
30
33
 
31
34
  SUBCOMMANDS = %w[init].freeze
32
35
  FLAGS = {
33
36
  nil => CLI::Flags.new(help: HELP_WORDS, args: false),
34
- "init" => CLI::Flags.new(help: HELP_WORDS)
37
+ "init" => CLI::Flags.new(help: HELP_WORDS) { |f| f.switch "--new-id" }
35
38
  }.freeze
36
39
 
37
40
  # @param cwd [String] the folder asked about (Dir.pwd)
38
41
  # @param home [String, nil] $HOME (markers at or above it are ignored)
39
42
  # @param sessions [#call, nil] () → this machine's SessionRows (for
40
- # init's restart note; SessionManager.session_rows by default)
43
+ # init's restart note and its adoption's running check;
44
+ # SessionManager.session_rows by default)
45
+ # @param state_dir [String, nil] the sessions dir (an adoption moves the
46
+ # project context folders beside it)
41
47
  def initialize(argv, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd, env: ENV, home: MemoryPaths.default_home,
42
- sessions: nil)
48
+ sessions: nil, state_dir: nil)
43
49
  @argv = argv.dup
44
50
  @stdout = stdout
45
51
  @stderr = stderr
46
52
  @cwd = cwd
47
53
  @env = env
48
54
  @home = home
55
+ @state_dir = state_dir
49
56
  @sessions = sessions || lambda {
50
57
  require_relative "session_manager"
51
58
  SessionManager.session_rows
@@ -61,7 +68,8 @@ module Samagotchi
61
68
  return run_show unless @sub
62
69
  return usage_error("init takes one folder") if parsed.args.size > 1
63
70
 
64
- run_init(parsed.args.first || @cwd)
71
+ dir = File.expand_path(parsed.args.first || @cwd, @cwd)
72
+ parsed.options[:new_id] ? run_new_id(dir) : run_init(dir)
65
73
  end
66
74
 
67
75
  private
@@ -78,15 +86,17 @@ module Samagotchi
78
86
  row("root", found.root)
79
87
  row("top", found.top)
80
88
  end
81
- row("memories", memories_line(found.root))
89
+ row("id", found.id) if found.id
90
+ row("memories", memories_line(found))
82
91
  notes(found).each { |note| row("note", note) }
83
92
  CLI::Exit::OK
84
93
  end
85
94
 
86
95
  def row(label, value) = @stdout.puts("#{label.ljust(9)}#{value}")
87
96
 
88
- def memories_line(root)
89
- dir = File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for_root(root))
97
+ # @param found [MemoryPaths::ProjectLookup] the project (its root and id key the folder)
98
+ def memories_line(found)
99
+ dir = File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for(root: found.root, id: found.id))
90
100
  count = memory_count(dir)
91
101
  "#{Paths.tilde(dir)}#{" (#{count} #{count == 1 ? "memory" : "memories"})" if count.positive?}"
92
102
  end
@@ -115,8 +125,7 @@ module Samagotchi
115
125
  end
116
126
 
117
127
  def run_init(dir)
118
- dir = File.expand_path(dir, @cwd)
119
- outcome = ProjectInit.call(dir, home: @home)
128
+ outcome = ProjectInit.adopt(dir, home: @home, env: @env, state_dir: @state_dir, sessions: adopt_rows(dir))
120
129
  case outcome.kind
121
130
  when :git
122
131
  found = MemoryPaths::ProjectLookup.new(kind: :git, root: outcome.root, top: outcome.top)
@@ -130,7 +139,10 @@ module Samagotchi
130
139
  @stdout.puts(outcome.message)
131
140
  CLI::Exit::OK
132
141
  when :created
133
- report_init(outcome.root, nested_in: outcome.nested_in)
142
+ report_init(outcome)
143
+ CLI::Exit::OK
144
+ when :adopted
145
+ @stdout.puts(outcome.message)
134
146
  CLI::Exit::OK
135
147
  end
136
148
  rescue SystemCallError => e
@@ -138,10 +150,34 @@ module Samagotchi
138
150
  CLI::Exit::FAILED
139
151
  end
140
152
 
141
- def report_init(root, nested_in:)
142
- nested = nested_in ? ", its own project nested in #{Paths.tilde(nested_in)}" : ""
153
+ def run_new_id(dir)
154
+ outcome = ProjectInit.new_id(dir, home: @home, env: @env, state_dir: @state_dir)
155
+ if outcome.kind == :refused
156
+ error_line("#{command_name} --new-id: #{outcome.message}")
157
+ return CLI::Exit::FAILED
158
+ end
159
+
160
+ @stdout.puts(outcome.message)
161
+ sessions_note(outcome.root, restart: "keep the id they started with until restarted")
162
+ CLI::Exit::OK
163
+ end
164
+
165
+ # The rows an adoption checks for running sessions: only for a marked
166
+ # folder (a listing is a walk of every session file).
167
+ def adopt_rows(dir)
168
+ found = lookup(dir)
169
+ return [] if found.git? || found.id
170
+
171
+ @sessions.call
172
+ end
173
+
174
+ def report_init(outcome)
175
+ root = outcome.root
176
+ nested = outcome.nested_in ? ", its own project nested in #{Paths.tilde(outcome.nested_in)}" : ""
143
177
  @stdout.puts("#{Paths.tilde(root)} is now a chi project#{nested} (#{Paths.tilde(File.join(root, MemoryPaths::MARKER))})")
144
- row("memories", memories_line(root))
178
+ row("id", outcome.id) if outcome.id
179
+ row("memories", memories_line(lookup(root)))
180
+ @stdout.puts(outcome.message) if outcome.message
145
181
  sessions_note(root)
146
182
  end
147
183
 
@@ -150,15 +186,17 @@ module Samagotchi
150
186
  # prompt (the memories folder line, the AGENTS.md chain) until it
151
187
  # restarts; a running chi web groups the older ones after its restart
152
188
  # (SessionHub looked their project up once).
153
- def sessions_note(root)
189
+ def sessions_note(root, restart: nil)
154
190
  rows = @sessions.call.select { |r| !r.cwd.to_s.empty? && lookup(r.cwd.to_s).root == root }
155
191
  return if rows.empty?
156
192
 
157
193
  live = rows.select(&:live)
158
194
  unless live.empty?
159
- @stdout.puts("running sessions in it keep the old project in their prompt until restarted " \
195
+ @stdout.puts("running sessions in it #{restart || "keep the old project in their prompt until restarted"} " \
160
196
  "(chi sessions restart ID): #{live.map { |r| r.id[0, 8] }.join(", ")}")
161
197
  end
198
+ return if restart
199
+
162
200
  @stdout.puts("#{rows.size} saved session(s) in it join the project in a running chi web after its restart")
163
201
  rescue StandardError => e
164
202
  error_line("#{command_name}: couldn't list this machine's sessions (#{e.message})")
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "yaml"
5
+ require_relative "log"
6
+
7
+ module Samagotchi
8
+ # A marked project's id: the `id:` line of its .chi/project.yml
9
+ # (MemoryPaths::MARKER), written by `chi project init`. A marked project
10
+ # with an id keys its memories and project context sources by the id
11
+ # (MemoryPaths.key_for), so moving or renaming the folder keeps them; one
12
+ # without (an older marker, a bad hand edit) keys by its path. Git
13
+ # repositories never have one: only MemoryPaths.lookup's :marker kind
14
+ # reads it.
15
+ #
16
+ # Ids are `<basename>-<8 hex>`, a hyphen where a path key has its
17
+ # underscore, so MemoryOrphans' "same basename, other hash" pattern never
18
+ # takes an id folder for a moved project's path key.
19
+ module ProjectId
20
+ # A valid id: what a folder name and a URL segment can hold.
21
+ PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\z/
22
+ # The marker is a small file; anything longer is read this far.
23
+ READ_LIMIT = 4096
24
+
25
+ # What a marker says: +id+ (a valid id, else nil) and +problem+ (why a
26
+ # present `id:` is no id: "the id line is not valid YAML" …; nil with a
27
+ # valid id or no id line at all). The one wording: the resolver's debug
28
+ # line and `chi project init`'s report both say +problem+.
29
+ Read = Data.define(:id, :problem) do
30
+ def id? = !id.nil?
31
+ end
32
+
33
+ NONE = Read.new(id: nil, problem: nil)
34
+
35
+ module_function
36
+
37
+ # The marker at +root+ (a marked folder), read once: never raises.
38
+ # @return [Read]
39
+ def read(root)
40
+ path = File.join(root, ".chi", "project.yml")
41
+ parse(File.open(path, "rb") { |file| file.read(READ_LIMIT) }.to_s)
42
+ rescue SystemCallError, IOError => e
43
+ Read.new(id: nil, problem: "the marker can't be read (#{e.message})")
44
+ end
45
+
46
+ # @return [Read]
47
+ def parse(text)
48
+ text = text.dup.force_encoding(Encoding::UTF_8)
49
+ return Read.new(id: nil, problem: "the marker is not UTF-8 text") unless text.valid_encoding?
50
+
51
+ data = YAML.safe_load(text)
52
+ return NONE if data.nil?
53
+ return Read.new(id: nil, problem: "the marker is not a YAML mapping") unless data.is_a?(Hash)
54
+ return NONE unless data.key?("id")
55
+
56
+ check(data["id"])
57
+ rescue Psych::Exception
58
+ Read.new(id: nil, problem: "the marker is not valid YAML")
59
+ end
60
+
61
+ # @return [Read]
62
+ def check(value)
63
+ return Read.new(id: value, problem: nil) if value.is_a?(String) && value.match?(PATTERN)
64
+
65
+ Read.new(id: nil, problem: "its id #{value.inspect[0, 80]} is not a valid id " \
66
+ "(letters, digits, '_', '.', '-'; up to 64; a letter or digit first)")
67
+ end
68
+
69
+ # A fresh id for a folder named +basename+: the name's id-safe part and
70
+ # 8 random hex digits.
71
+ def generate(basename)
72
+ name = basename.to_s.gsub(/[^A-Za-z0-9_.-]+/, "-").sub(/\A[^A-Za-z0-9]+/, "")[0, 55].sub(/[-.]+\z/, "")
73
+ name = "project" if name.empty?
74
+ "#{name}-#{SecureRandom.hex(4)}"
75
+ end
76
+
77
+ # The marker's text with its id line set to +id+ (replaced, or appended),
78
+ # every other line kept.
79
+ def with_id(text, id)
80
+ line = "id: #{id}\n"
81
+ lines = text.to_s.lines
82
+ at = lines.index { |l| l.match?(/\Aid\s*:/) }
83
+ return lines.tap { |all| all[at] = line }.join if at
84
+
85
+ body = lines.join
86
+ body += "\n" unless body.empty? || body.end_with?("\n")
87
+ body + line
88
+ end
89
+
90
+ @logged = Set.new
91
+ @logged_lock = Mutex.new
92
+
93
+ # One debug line per (root, problem) and process: the resolver runs on
94
+ # every memory call.
95
+ def log_problem(root, read)
96
+ return unless read.problem
97
+
98
+ first = @logged_lock.synchronize { @logged.add?([root, read.problem]) }
99
+ Log.debug(:memory, "project_id_ignored", root: root, problem: read.problem) if first
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "atomic_file"
4
+ require_relative "memory_paths"
5
+ require_relative "paths"
6
+
7
+ module Samagotchi
8
+ # Two folders with one chi project id: a marked folder copied (cp -R)
9
+ # keeps its marker, so the copy shares the original's memories. Told at
10
+ # session start (Engine#announce_load_events!, label "memory", beside
11
+ # MemoryOrphans), at every session start while it lasts.
12
+ #
13
+ # The claim: an id-keyed memories folder's `.root` names the folder that
14
+ # last started a session with it (one line; a dotfile, so no *.md glob
15
+ # lists it). Written only here, at session start, never by the key
16
+ # resolver: a read-only command or a memory call must not write.
17
+ ProjectIdClash = Data.define(:root, :other, :id) do
18
+ # The one text for the condition.
19
+ def message
20
+ "#{Paths.tilde(root)} and #{Paths.tilde(other)} share chi project id #{id} (a copied folder?): they share " \
21
+ "memories; `chi project init --new-id` gives this one its own."
22
+ end
23
+ end
24
+
25
+ class ProjectIdClash
26
+ CLAIM = ".root"
27
+
28
+ # +cwd+'s project, when a marked one with an id: a clash when the claim
29
+ # names another folder that still has a marker with this id; otherwise
30
+ # the claim is set to this root (when it changes; a moved folder just
31
+ # takes it over). Only once the id folder exists: nothing is made for a
32
+ # project with no memories yet.
33
+ # @return [ProjectIdClash, nil]
34
+ def self.check(env: ENV, cwd: Dir.pwd, home: MemoryPaths.default_home)
35
+ found = MemoryPaths.lookup(cwd, home: home)
36
+ return nil unless found.id
37
+
38
+ dir = File.join(MemoryPaths.projects_dir(env: env), found.id)
39
+ return nil unless File.directory?(dir)
40
+
41
+ claim = File.join(dir, CLAIM)
42
+ other = File.file?(claim) ? File.read(claim, 4096).to_s.lines.first.to_s.strip : ""
43
+ return nil if other == found.root
44
+ return new(root: found.root, other: other, id: found.id) if same_project?(other, found.id, home)
45
+
46
+ AtomicFile.write(claim, "#{found.root}\n")
47
+ nil
48
+ rescue SystemCallError
49
+ nil
50
+ end
51
+
52
+ # Whether +other+ still is a marked folder with +id+ (not gone, not
53
+ # given another id).
54
+ def self.same_project?(other, id, home)
55
+ return false if other.empty? || !File.directory?(other)
56
+
57
+ found = MemoryPaths.lookup(other, home: home)
58
+ found.marker? && found.root == File.realpath(other) && found.id == id
59
+ end
60
+ private_class_method :same_project?
61
+ end
62
+ end
@@ -1,20 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "atomic_file"
5
+ require_relative "context_sources"
4
6
  require_relative "guardrails/path_resolve"
7
+ require_relative "memory_orphans"
5
8
  require_relative "memory_paths"
6
9
  require_relative "paths"
10
+ require_relative "project_id"
11
+ require_relative "session_paths"
7
12
 
8
13
  module Samagotchi
9
- # The shared decisions for `chi project init` and chi web's project picker.
14
+ # The shared decisions for `chi project init` and chi web's project picker,
15
+ # and every text they refuse or report with (the CLI prints them). A new
16
+ # marker gets an id (ProjectId); #adopt gives an older marker one, #new_id
17
+ # a copied folder its own.
10
18
  module ProjectInit
11
- Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message)
19
+ # +id+: the project's id (ProjectId), nil for none (a git repository, a
20
+ # marker without one, a refusal).
21
+ Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message, :id) do
22
+ def initialize(kind:, reason:, root:, top:, nested_in:, message:, id: nil) = super
23
+ end
24
+
25
+ # A folder keyed by the project key (MemoryPaths.key_for): under +base+,
26
+ # named +prefix+ + the key.
27
+ Place = Data.define(:base, :prefix) do
28
+ def path(key) = File.join(base, "#{prefix}#{key}")
29
+ end
12
30
 
13
31
  MARKER_TEXT = "# chi project marker: this folder is a chi project (docs/memory.md)\n"
14
32
 
15
33
  module_function
16
34
 
17
- def call(dir, home:)
35
+ # Marks +dir+ (a new marker gets an id), or says why not. A folder whose
36
+ # path key already names a folder (memories kept while it was no
37
+ # project, keyed by the same path) is marked without an id, so nothing
38
+ # it has goes out of reach: #adopt moves those and writes the id.
39
+ # @param state_dir [String, nil] the sessions dir (SessionPaths.default_state_dir)
40
+ def call(dir, home:, env: ENV, state_dir: nil)
18
41
  dir = File.expand_path(dir.to_s)
19
42
  found = MemoryPaths.lookup(dir, home: home)
20
43
  if found.git?
@@ -32,19 +55,94 @@ module Samagotchi
32
55
  # The marked project this folder sits in, if any: the folder itself (already marked) or its parent project.
33
56
  enclosing = found.marker? ? found.root : nil
34
57
  if enclosing == Guardrails::PathResolve.real(dir)
58
+ id_part = found.id ? " (id #{found.id})" : ""
35
59
  return Outcome.new(kind: :already_marked, reason: nil, root: enclosing, top: found.top, nested_in: nil,
36
- message: "#{Paths.tilde(enclosing)} is already a chi project")
60
+ message: "#{Paths.tilde(enclosing)} is already a chi project#{id_part}", id: found.id)
37
61
  end
38
62
 
39
- write_marker(dir)
63
+ id = write_marker(dir, places(env: env, state_dir: state_dir || SessionPaths.default_state_dir(env: env)))
40
64
  root = File.realpath(dir)
41
65
  # No message: each surface words its own success (the CLI's report, the web's navigation).
42
- Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil)
66
+ Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil, id: id)
43
67
  rescue SystemCallError => e
44
68
  marker = File.join(dir, MemoryPaths::MARKER)
45
69
  write_failed("couldn't write #{Paths.tilde(marker)}: #{e.message}")
46
70
  end
47
71
 
72
+ # `chi project init` (the CLI only: chi web's New-project row calls
73
+ # #call, which never moves folders): #call, and for a marked folder
74
+ # with no valid id, its adoption of one. The folders keyed by its path
75
+ # key (its memories, its project context sources, the skills bundle's
76
+ # kept versions) move to the id, each leaving a link by its old name so
77
+ # an older chi, keying by path, finds the same folder; then the id is
78
+ # written (a crash before that leaves the path key reaching the moved
79
+ # folders through the links, and a rerun finishes with the same id).
80
+ # Refused while a running session belongs to the project: its worker
81
+ # could write a memory under the old name between a move and its link.
82
+ # @param state_dir [String, nil] the sessions dir (SessionPaths.default_state_dir)
83
+ # @param sessions [Array<SessionRow>] this machine's sessions (+live+, +project+, +id+)
84
+ # A folder #call marked without an id is adopted the same way.
85
+ # @param sessions [Array<SessionRow>] this machine's sessions (+live+,
86
+ # +project+, +cwd+, +id+): a running one in the project (by the root it
87
+ # stored, else its folder's) refuses
88
+ # @return [Outcome] :adopted (its message the report), :created (its
89
+ # message what moved, nil when nothing did) or #call's
90
+ def adopt(dir, home:, env: ENV, state_dir: nil, sessions: [])
91
+ state_dir ||= SessionPaths.default_state_dir(env: env)
92
+ outcome = call(dir, home: home, env: env, state_dir: state_dir)
93
+ return outcome unless %i[already_marked created].include?(outcome.kind) && outcome.id.nil?
94
+
95
+ root = outcome.root
96
+ running = Array(sessions).select do |row|
97
+ row.live && (row.project ? row.project == root : MemoryPaths.lookup(row.cwd.to_s, home: home).root == root)
98
+ end
99
+ unless running.empty?
100
+ return refused(:running, root, "#{Paths.tilde(root)} has running sessions " \
101
+ "(#{running.map { |row| row.id[0, 8] }.join(", ")}): stop them " \
102
+ "(chi sessions stop ID) and rerun chi project init, which moves the " \
103
+ "project's folders while no session can write to them")
104
+ end
105
+
106
+ adopted = adopt_id(root, env: env, state_dir: state_dir)
107
+ return adopted unless outcome.kind == :created && adopted.kind == :adopted
108
+
109
+ # A new project: the CLI words its success; the report keeps what moved.
110
+ moved = adopted.message.lines.drop(1).join.chomp
111
+ outcome.with(id: adopted.id, message: moved.empty? ? nil : moved)
112
+ rescue SystemCallError => e
113
+ write_failed("couldn't adopt an id for #{Paths.tilde(outcome&.root || dir.to_s)}: #{e.message}")
114
+ end
115
+
116
+ # `chi project init --new-id`: a fresh id for a marked folder (a copied
117
+ # one, going its own way), its folders copied from the key it had to the
118
+ # new id (the copy keeps what it had; the other folder keeps its own).
119
+ # @return [Outcome] :new_id (its message the report) or :refused
120
+ def new_id(dir, home:, env: ENV, state_dir: nil)
121
+ dir = File.expand_path(dir.to_s)
122
+ found = MemoryPaths.lookup(dir, home: home)
123
+ unless found.marker? && found.root == Guardrails::PathResolve.real(dir)
124
+ return refused(:not_marked, nil, "#{Paths.tilde(dir)} is not a marked chi project folder: --new-id gives " \
125
+ "one (made with chi project init) an id of its own")
126
+ end
127
+
128
+ root = found.root
129
+ old_key = MemoryPaths.key_for(root: root, id: found.id)
130
+ id = ProjectId.generate(File.basename(root))
131
+ lines = ["#{Paths.tilde(root)} has a chi project id of its own now: #{id} (was #{found.id || "none: keyed by its path"})"]
132
+ places(env: env, state_dir: state_dir || SessionPaths.default_state_dir(env: env)).each do |place|
133
+ from = place.path(old_key)
134
+ next unless File.directory?(from)
135
+
136
+ to = place.path(id)
137
+ copy_folder(File.realpath(from), to)
138
+ lines << "copied #{Paths.tilde(from)} to #{Paths.tilde(to)}"
139
+ end
140
+ write_id(root, id)
141
+ Outcome.new(kind: :new_id, reason: nil, root: root, top: root, nested_in: nil, message: lines.join("\n"), id: id)
142
+ rescue SystemCallError => e
143
+ write_failed("couldn't give #{Paths.tilde(dir)} a new id: #{e.message}")
144
+ end
145
+
48
146
  # chi web opens a folder in a git repository without marking it, so the folder has to exist.
49
147
  # @return [Outcome, nil] a refused (:write_failed) outcome when it can't be made, else nil
50
148
  def ensure_folder(dir)
@@ -54,16 +152,122 @@ module Samagotchi
54
152
  write_failed("couldn't create #{Paths.tilde(dir)}: #{e.message}")
55
153
  end
56
154
 
155
+ # The folders a project key names: its memories, its project context
156
+ # sources (where the workers keep them: ContextSources.root), and the
157
+ # skills bundle's kept versions of its project skills (history/
158
+ # project-<key>, the bundle's own data dir).
159
+ # @return [Array<Place>]
160
+ def places(env:, state_dir:)
161
+ [Place.new(base: MemoryPaths.projects_dir(env: env), prefix: ""),
162
+ Place.new(base: File.join(ContextSources.root(state_dir: state_dir), "projects"), prefix: ""),
163
+ Place.new(base: File.join(File.dirname(state_dir), "plugins", "skills", "history"), prefix: "project-")]
164
+ end
165
+
166
+ def adopt_id(root, env:, state_dir:)
167
+ problem = ProjectId.read(root).problem
168
+ path_key = MemoryPaths.key_for_root(root)
169
+ # Before the id: what MemoryOrphans finds for the project keyed by its path.
170
+ orphans = MemoryOrphans.find(env: env, cwd: root)
171
+ list = places(env: env, state_dir: state_dir)
172
+ id = resumed_id(list.first, path_key) || ProjectId.generate(File.basename(root))
173
+ taken = list.map { |place| [place.path(path_key), place.path(id)] }
174
+ .find { |from, to| real_dir?(from) && (File.exist?(to) || File.symlink?(to)) }
175
+ if taken
176
+ return refused(:id_taken, root, "#{Paths.tilde(taken[1])} already exists: chi project init can't move " \
177
+ "#{Paths.tilde(taken[0])} there; nothing moved")
178
+ end
179
+
180
+ lines = ["#{Paths.tilde(root)} now has the chi project id #{id}: its memories and project context stay " \
181
+ "with it when the folder moves or is renamed"]
182
+ lines << "#{problem}: replaced" if problem
183
+ list.each { |place| lines.concat(move_folder(place.path(path_key), place.path(id))) }
184
+ write_id(root, id)
185
+ if orphans
186
+ keys = orphans.candidates.map { |candidate| "#{Paths.tilde(candidate)} (#{MemoryOrphans.key_note(candidate)})" }
187
+ lines << "memories with this folder's name under another key: #{keys.join(", ")}; if they are this " \
188
+ "project's, move their files into #{Paths.tilde(list.first.path(id))}"
189
+ end
190
+ Outcome.new(kind: :adopted, reason: nil, root: root, top: root, nested_in: nil, message: lines.join("\n"), id: id)
191
+ end
192
+
193
+ # The id an adoption that crashed before writing it chose: the path
194
+ # key already a link to an id folder beside it.
195
+ def resumed_id(place, path_key)
196
+ link = place.path(path_key)
197
+ return nil unless File.symlink?(link)
198
+
199
+ target = File.readlink(link)
200
+ target if target.match?(ProjectId::PATTERN) && File.directory?(place.path(target))
201
+ end
202
+
203
+ def real_dir?(path) = File.directory?(path) && !File.symlink?(path)
204
+
205
+ # Move +from+ to +to+ and leave a link named +from+ (relative: the
206
+ # folder may move with its parent). Nothing to move (no folder, or a
207
+ # link already): no move and no link. A folder made again at +from+
208
+ # between the move and the link has its files moved over (none
209
+ # replaced), then gives way to the link; what was in both stays.
210
+ # @return [Array<String>] the report's lines
211
+ def move_folder(from, to)
212
+ return [] unless real_dir?(from)
213
+
214
+ File.rename(from, to)
215
+ begin
216
+ File.symlink(File.basename(to), from)
217
+ rescue Errno::EEXIST
218
+ Dir.children(from).each do |name|
219
+ File.rename(File.join(from, name), File.join(to, name)) unless File.exist?(File.join(to, name))
220
+ end
221
+ unless Dir.empty?(from)
222
+ return ["moved #{Paths.tilde(from)} to #{Paths.tilde(to)}; files written to the old folder meanwhile " \
223
+ "that #{Paths.tilde(to)} has too stay in #{Paths.tilde(from)}"]
224
+ end
225
+
226
+ Dir.rmdir(from)
227
+ File.symlink(File.basename(to), from)
228
+ end
229
+ ["moved #{Paths.tilde(from)} to #{Paths.tilde(to)} (the old name links to it, for an older chi)"]
230
+ end
231
+
232
+ # +to+ made a copy of +from+'s files, without the copied project's
233
+ # claim (.root: ProjectIdClash).
234
+ def copy_folder(from, to)
235
+ FileUtils.mkdir_p(to)
236
+ FileUtils.cp_r(File.join(from, "."), to)
237
+ FileUtils.rm_f(File.join(to, ".root"))
238
+ end
239
+
240
+ def write_id(root, id)
241
+ marker = File.join(root, MemoryPaths::MARKER)
242
+ AtomicFile.write(marker, ProjectId.with_id(File.read(marker), id))
243
+ end
244
+
245
+ def refused(reason, root, message)
246
+ Outcome.new(kind: :refused, reason: reason, root: root, top: root, nested_in: nil, message: message)
247
+ end
248
+
57
249
  def write_failed(message)
58
250
  Outcome.new(kind: :refused, reason: :write_failed, root: nil, top: nil, nested_in: nil, message: message)
59
251
  end
60
- private_class_method :write_failed
61
252
 
62
- def write_marker(dir)
253
+ # @param places [Array<Place>] the folders a key names (#places)
254
+ # @return [String, nil] the new marker's id; nil when a folder already
255
+ # has the path key (#call)
256
+ def write_marker(dir, places)
63
257
  marker = File.join(dir, MemoryPaths::MARKER)
64
258
  FileUtils.mkdir_p(File.dirname(marker))
65
- File.write(marker, MARKER_TEXT)
259
+ root = File.realpath(dir)
260
+ key = MemoryPaths.key_for_root(root)
261
+ if places.any? { |place| File.exist?(place.path(key)) || File.symlink?(place.path(key)) }
262
+ File.write(marker, MARKER_TEXT)
263
+ return nil
264
+ end
265
+
266
+ id = ProjectId.generate(File.basename(root))
267
+ File.write(marker, ProjectId.with_id(MARKER_TEXT, id))
268
+ id
66
269
  end
67
- private_class_method :write_marker
270
+ private_class_method :write_failed, :write_marker, :refused, :adopt_id, :resumed_id, :real_dir?, :move_folder,
271
+ :copy_folder, :write_id
68
272
  end
69
273
  end
@@ -18,10 +18,19 @@ module Samagotchi
18
18
  return nil if dir.empty?
19
19
  return cache[dir] if cache&.key?(dir)
20
20
 
21
- found = MemoryPaths.lookup(File.expand_path(dir))
22
- root = found.project? ? found.root : nil
21
+ root = project_for(dir)&.root
23
22
  cache[dir] = root if cache
24
23
  root
25
24
  end
25
+
26
+ # +dir+'s project (MemoryPaths::ProjectLookup: its root and, for a
27
+ # marked project, its id), or nil in none.
28
+ def project_for(dir)
29
+ dir = dir.to_s
30
+ return nil if dir.empty?
31
+
32
+ found = MemoryPaths.lookup(File.expand_path(dir))
33
+ found.project? ? found : nil
34
+ end
26
35
  end
27
36
  end
@@ -80,7 +80,8 @@ module Samagotchi
80
80
  setup = setup.with(toolkit_origin: "chosen")
81
81
  SessionManager.spawn_session(prompt: nil, preview: preview(subjects), working_directory: dir, model_name: model,
82
82
  setup: setup, review: SessionReview.new(subjects: subjects),
83
- note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir)
83
+ note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir,
84
+ client_id: nil)
84
85
  end
85
86
 
86
87
  # A review fork of +from+ (an id, a prefix, or last:<id>): a new session
@@ -106,9 +107,10 @@ module Samagotchi
106
107
  recap = RecapStore.read(Session.session_dir(source.id, state_dir: sd))
107
108
  session = SessionManager.spawn_session(
108
109
  prompt: nil, preview: preview(subjects, source: source), working_directory: dir, model_name: choice.model,
109
- setup: SessionSetup.of(source).with(toolkit_origin: "chosen"),
110
+ setup: SessionSetup.of(source).with(preloaded_memory_names: [], toolkit_origin: "chosen"),
110
111
  review: SessionReview.new(subjects: subjects, from: source.id),
111
- note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE, state_dir: sd
112
+ note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE,
113
+ state_dir: sd, client_id: nil
112
114
  )
113
115
  warnings = [choice.warning, session.model_warning].compact
114
116
  session.model_warning = warnings.join("; ") unless warnings.empty?