samagotchi 0.159.0 → 0.160.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/architecture.md +5 -6
  4. data/docs/cli.md +1 -1
  5. data/docs/guardrails.md +21 -2
  6. data/docs/internals/attached-context.md +4 -2
  7. data/docs/internals/bridge.md +7 -0
  8. data/docs/internals/engine.md +7 -1
  9. data/docs/memory.md +41 -13
  10. data/docs/scheduling.md +16 -3
  11. data/docs/sessions.md +3 -1
  12. data/lib/samagotchi/bridge.rb +11 -2
  13. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  14. data/lib/samagotchi/bundle_command.rb +1 -0
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  17. data/lib/samagotchi/client_id.rb +4 -0
  18. data/lib/samagotchi/command_intake.rb +56 -0
  19. data/lib/samagotchi/context_absorber.rb +6 -3
  20. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  21. data/lib/samagotchi/context_candidates.rb +11 -8
  22. data/lib/samagotchi/context_command.rb +9 -3
  23. data/lib/samagotchi/context_poller.rb +5 -2
  24. data/lib/samagotchi/context_sources.rb +6 -4
  25. data/lib/samagotchi/engine/events.rb +13 -2
  26. data/lib/samagotchi/engine/guardrails.rb +3 -4
  27. data/lib/samagotchi/engine.rb +1 -0
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/events.rb +71 -0
  30. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  31. data/lib/samagotchi/hook_routes.rb +0 -1
  32. data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
  33. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  34. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  35. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  36. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  37. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  38. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  39. data/lib/samagotchi/memory_bundle/status.rb +2 -2
  40. data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
  41. data/lib/samagotchi/memory_orphans.rb +14 -4
  42. data/lib/samagotchi/memory_paths.rb +29 -6
  43. data/lib/samagotchi/memory_usage.rb +23 -12
  44. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  45. data/lib/samagotchi/plugin/sessions.rb +4 -1
  46. data/lib/samagotchi/project_command.rb +58 -20
  47. data/lib/samagotchi/project_id.rb +102 -0
  48. data/lib/samagotchi/project_id_clash.rb +62 -0
  49. data/lib/samagotchi/project_init.rb +214 -10
  50. data/lib/samagotchi/project_scope.rb +11 -2
  51. data/lib/samagotchi/schedules.rb +99 -39
  52. data/lib/samagotchi/session.rb +17 -2
  53. data/lib/samagotchi/session_access/local.rb +8 -5
  54. data/lib/samagotchi/session_access/remote.rb +2 -1
  55. data/lib/samagotchi/session_access.rb +4 -1
  56. data/lib/samagotchi/session_commands.rb +7 -3
  57. data/lib/samagotchi/session_continue.rb +10 -6
  58. data/lib/samagotchi/session_manager/spawn.rb +9 -2
  59. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  60. data/lib/samagotchi/state_format.rb +118 -0
  61. data/lib/samagotchi/tools/context_read.rb +7 -2
  62. data/lib/samagotchi/tools/delegate.rb +10 -2
  63. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  64. data/lib/samagotchi/update_command.rb +2 -2
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +5 -4
  67. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  68. data/lib/samagotchi/worker.rb +18 -6
  69. data/lib/samagotchi/worker_inbound.rb +21 -2
  70. metadata +5 -1
@@ -52,7 +52,8 @@ module Samagotchi
52
52
  # mismatch warnings. merged and replaced are file
53
53
  # names, behind FileStanding::Behind; under plan they are what apply would
54
54
  # merge, replace or leave behind.
55
- # adds is nil but for a profile: the members it installs.
55
+ # added_files is the new version's memory files the bundle didn't own
56
+ # before; adds is nil but for a profile: the members it installs.
56
57
  # kept_own is nil but for a profile that installed a member: its
57
58
  # Profile.kept_own_line lines.
58
59
  # trash_copy is nil but under apply when it saved edited files
@@ -62,8 +63,8 @@ module Samagotchi
62
63
  # manifest: what a row at the shipped version says of its manifest.yml
63
64
  # (ManifestStanding.keep's line: would keep or kept it, or why not and
64
65
  # the command), nil when nothing.
65
- Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :adds, :kept_own,
66
- :trash_copy, :earlier, :manifest, keyword_init: true) do
66
+ Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :added_files, :adds,
67
+ :kept_own, :trash_copy, :earlier, :manifest, keyword_init: true) do
67
68
  def kept = Array(behind).map(&:file)
68
69
  end
69
70
 
@@ -208,6 +209,7 @@ module Samagotchi
208
209
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
209
210
  return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
210
211
 
212
+ row.added_files = (manifest.files.keys - bundle.files.keys).sort
211
213
  forecast = forecast(row, bundle)
212
214
  row.merged = forecast.merged
213
215
  row.behind = forecast.behind
@@ -84,12 +84,12 @@ module Samagotchi
84
84
  private_class_method :digest_or_nil
85
85
 
86
86
  # The class-file hooks the record still has (a 0.157 install's; chi
87
- # loads none, Manifest::HooksRetired), each "ok" or "missing" in
87
+ # loads none, Manifest::HooksRetired), each "present" or "missing" in
88
88
  # hooks/, by name; {} for none.
89
89
  # @return [Hash{String => String}]
90
90
  def self.retired_hooks(provenance, bundle)
91
91
  bundle.hooks.keys.sort.to_h do |file|
92
- [file, File.exist?(File.join(provenance.hooks_dir, file)) ? "ok" : "missing"]
92
+ [file, File.exist?(File.join(provenance.hooks_dir, file)) ? "present" : "missing"]
93
93
  end
94
94
  end
95
95
 
@@ -56,6 +56,8 @@ module Samagotchi
56
56
  # No file list, just remove provenance (rules, a plugin and scripts
57
57
  # live there too)
58
58
  save_edits(provenance, bundle)
59
+ plugin_path = provenance.plugin_path(bundle)
60
+ @removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
59
61
  FileUtils.rm_rf(provenance.bundle_dir)
60
62
  return true
61
63
  end
@@ -6,7 +6,13 @@ 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
@@ -33,10 +39,14 @@ module Samagotchi
33
39
  return nil if File.directory?(computed) && Dir.children(computed).any? { |name| name.end_with?(".md") }
34
40
 
35
41
  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/
42
+ # The root's basename, never a split of a key on "_" (a basename may hold one): another path's key
43
+ # (<basename>_<8 hex>), or a marked project's id folder (<basename>-<8 hex>, ProjectId) a `git init`
44
+ # in the marked folder (git wins: keyed by its path again) left behind.
45
+ pattern = /\A#{Regexp.escape(File.basename(MemoryPaths.project_root(cwd)))}[_-]\h{8}\z/
38
46
  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) }
47
+ # A link is an adopted project's old name (ProjectInit.adopt), never an orphan.
48
+ candidates = names.sort.map { |name| File.join(projects, name) }
49
+ .select { |path| File.directory?(path) && !File.symlink?(path) }
40
50
  return nil if candidates.empty?
41
51
 
42
52
  Found.new(computed: computed, candidates: candidates, computed_exists: File.directory?(computed))
@@ -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.
@@ -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
@@ -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