samagotchi 0.157.0 → 0.158.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -1
  5. data/docs/architecture.md +7 -6
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +21 -17
  9. data/docs/configuration.md +7 -5
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +8 -7
  12. data/docs/hooks.md +44 -80
  13. data/docs/internals/bridge.md +1 -1
  14. data/docs/internals/bundles.md +47 -34
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +39 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/sessions.md +5 -5
  20. data/docs/testing.md +4 -4
  21. data/lib/samagotchi/bridge/card_places.rb +5 -13
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  23. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  24. data/lib/samagotchi/bundle_command/check.rb +23 -10
  25. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  26. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  27. data/lib/samagotchi/bundle_command.rb +15 -43
  28. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  31. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  32. data/lib/samagotchi/context_command.rb +7 -5
  33. data/lib/samagotchi/context_sources.rb +4 -2
  34. data/lib/samagotchi/engine.rb +1 -6
  35. data/lib/samagotchi/extension_load.rb +20 -41
  36. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  37. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  38. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  39. data/lib/samagotchi/hook_routes.rb +2 -2
  40. data/lib/samagotchi/hooks.rb +2 -3
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +39 -119
  42. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  43. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  46. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  47. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  48. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  49. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  50. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  51. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +30 -35
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -10
  54. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  55. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  56. data/lib/samagotchi/memory_orphans.rb +4 -4
  57. data/lib/samagotchi/memory_paths.rb +102 -13
  58. data/lib/samagotchi/plugin/api.rb +1 -4
  59. data/lib/samagotchi/plugin/loader.rb +42 -5
  60. data/lib/samagotchi/project_command.rb +167 -0
  61. data/lib/samagotchi/project_init.rb +69 -0
  62. data/lib/samagotchi/project_scope.rb +6 -4
  63. data/lib/samagotchi/prompt_files.rb +10 -7
  64. data/lib/samagotchi/recap_place.rb +3 -4
  65. data/lib/samagotchi/run_options.rb +1 -0
  66. data/lib/samagotchi/session.rb +44 -18
  67. data/lib/samagotchi/session_access/contract.rb +1 -1
  68. data/lib/samagotchi/session_field.rb +5 -4
  69. data/lib/samagotchi/steer.rb +10 -0
  70. data/lib/samagotchi/system_prompt.rb +20 -11
  71. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  72. data/lib/samagotchi/testing/bundle.rb +5 -12
  73. data/lib/samagotchi/testing/install.rb +5 -5
  74. data/lib/samagotchi/testing/recorder.rb +1 -1
  75. data/lib/samagotchi/testing.rb +1 -1
  76. data/lib/samagotchi/tool_declarations.rb +1 -1
  77. data/lib/samagotchi/toolkit_binding.rb +1 -4
  78. data/lib/samagotchi/toolkit_command.rb +1 -4
  79. data/lib/samagotchi/tools/delegate.rb +12 -8
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +35 -0
  82. data/lib/samagotchi/web/project_list.rb +3 -2
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/project_picker.js +86 -4
  85. metadata +3 -2
  86. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/command"
4
+ require_relative "cli/exit"
5
+ require_relative "cli/flags"
6
+ require_relative "guardrails/path_resolve"
7
+ require_relative "memory_paths"
8
+ require_relative "paths"
9
+ require_relative "project_init"
10
+
11
+ module Samagotchi
12
+ # `chi project`: which project this folder is in (MemoryPaths.lookup: a
13
+ # git repository, a folder marked with `.chi/project.yml`, or none) and
14
+ # its project memories folder; `chi project init [DIR]` marks a folder in
15
+ # no git repository as a project. Git wins: a repository is already the
16
+ # project, so init writes nothing there. Machine-local
17
+ # (SessionAccess::Contract::MACHINE).
18
+ class ProjectCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi project [init [DIR]]
23
+ (none) this folder's project: how it was found (git, marker or none), its root, its
24
+ top and its project memories folder
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
29
+ TEXT
30
+
31
+ SUBCOMMANDS = %w[init].freeze
32
+ FLAGS = {
33
+ nil => CLI::Flags.new(help: HELP_WORDS, args: false),
34
+ "init" => CLI::Flags.new(help: HELP_WORDS)
35
+ }.freeze
36
+
37
+ # @param cwd [String] the folder asked about (Dir.pwd)
38
+ # @param home [String, nil] $HOME (markers at or above it are ignored)
39
+ # @param sessions [#call, nil] () → this machine's SessionRows (for
40
+ # init's restart note; SessionManager.session_rows by default)
41
+ def initialize(argv, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd, env: ENV, home: MemoryPaths.default_home,
42
+ sessions: nil)
43
+ @argv = argv.dup
44
+ @stdout = stdout
45
+ @stderr = stderr
46
+ @cwd = cwd
47
+ @env = env
48
+ @home = home
49
+ @sessions = sessions || lambda {
50
+ require_relative "session_manager"
51
+ SessionManager.session_rows
52
+ }
53
+ end
54
+
55
+ # @return [Integer] 0, 1 (a refusal), 2 usage
56
+ def run
57
+ @sub = SUBCOMMANDS.include?(@argv.first) ? @argv.first : nil
58
+ parsed = parse_flags(FLAGS.fetch(@sub), @sub ? @argv[1..] : @argv)
59
+ return parsed if parsed.is_a?(Integer)
60
+
61
+ return run_show unless @sub
62
+ return usage_error("init takes one folder") if parsed.args.size > 1
63
+
64
+ run_init(parsed.args.first || @cwd)
65
+ end
66
+
67
+ private
68
+
69
+ def command_name = @sub ? "chi project #{@sub}" : "chi project"
70
+ def usage_on_error = nil
71
+
72
+ def lookup(dir) = MemoryPaths.lookup(dir, home: @home)
73
+
74
+ def run_show
75
+ found = lookup(@cwd)
76
+ row("kind", found.kind.to_s)
77
+ if found.project?
78
+ row("root", found.root)
79
+ row("top", found.top)
80
+ end
81
+ row("memories", memories_line(found.root))
82
+ notes(found).each { |note| row("note", note) }
83
+ CLI::Exit::OK
84
+ end
85
+
86
+ def row(label, value) = @stdout.puts("#{label.ljust(9)}#{value}")
87
+
88
+ def memories_line(root)
89
+ dir = File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for_root(root))
90
+ count = memory_count(dir)
91
+ "#{Paths.tilde(dir)}#{" (#{count} #{count == 1 ? "memory" : "memories"})" if count.positive?}"
92
+ end
93
+
94
+ def memory_count(dir)
95
+ return 0 unless File.directory?(dir)
96
+
97
+ Dir.children(dir).count { |name| name.end_with?(".md") && name != "index.md" }
98
+ end
99
+
100
+ def notes(found)
101
+ case found.kind
102
+ when :git
103
+ list = []
104
+ list << "#{Paths.tilde(found.top)} is $HOME, a git repository: every folder under it is in that repository" if home_repo?(found)
105
+ marker = MemoryPaths.nearest_marker(@cwd, home: @home)
106
+ list << "#{Paths.tilde(File.join(marker, MemoryPaths::MARKER))} is ignored: the git repository is the project" if marker
107
+ list
108
+ when :none then ["no git repository or chi project marker here: chi project init makes this folder a project"]
109
+ else []
110
+ end
111
+ end
112
+
113
+ def home_repo?(found)
114
+ !@home.to_s.empty? && Guardrails::PathResolve.real(found.top) == Guardrails::PathResolve.real(@home)
115
+ end
116
+
117
+ def run_init(dir)
118
+ dir = File.expand_path(dir, @cwd)
119
+ outcome = ProjectInit.call(dir, home: @home)
120
+ case outcome.kind
121
+ when :git
122
+ found = MemoryPaths::ProjectLookup.new(kind: :git, root: outcome.root, top: outcome.top)
123
+ note = home_repo?(found) ? " ($HOME is a git repository: every folder under it is in it)" : ""
124
+ @stdout.puts("#{outcome.message}#{note}")
125
+ CLI::Exit::OK
126
+ when :refused
127
+ error_line("#{command_name}: #{outcome.message}")
128
+ CLI::Exit::FAILED
129
+ when :already_marked
130
+ @stdout.puts(outcome.message)
131
+ CLI::Exit::OK
132
+ when :created
133
+ report_init(outcome.root, nested_in: outcome.nested_in)
134
+ CLI::Exit::OK
135
+ end
136
+ rescue SystemCallError => e
137
+ error_line("#{command_name}: couldn't write #{Paths.tilde(File.join(dir, MemoryPaths::MARKER))}: #{e.message}")
138
+ CLI::Exit::FAILED
139
+ end
140
+
141
+ def report_init(root, nested_in:)
142
+ nested = nested_in ? ", its own project nested in #{Paths.tilde(nested_in)}" : ""
143
+ @stdout.puts("#{Paths.tilde(root)} is now a chi project#{nested} (#{Paths.tilde(File.join(root, MemoryPaths::MARKER))})")
144
+ row("memories", memories_line(root))
145
+ sessions_note(root)
146
+ end
147
+
148
+ # Sessions in the new project (not one under DIR that a nested
149
+ # repository or marked folder keeps): a running one keeps its old project in its
150
+ # prompt (the memories folder line, the AGENTS.md chain) until it
151
+ # restarts; a running chi web groups the older ones after its restart
152
+ # (SessionHub looked their project up once).
153
+ def sessions_note(root)
154
+ rows = @sessions.call.select { |r| !r.cwd.to_s.empty? && lookup(r.cwd.to_s).root == root }
155
+ return if rows.empty?
156
+
157
+ live = rows.select(&:live)
158
+ unless live.empty?
159
+ @stdout.puts("running sessions in it keep the old project in their prompt until restarted " \
160
+ "(chi sessions restart ID): #{live.map { |r| r.id[0, 8] }.join(", ")}")
161
+ end
162
+ @stdout.puts("#{rows.size} saved session(s) in it join the project in a running chi web after its restart")
163
+ rescue StandardError => e
164
+ error_line("#{command_name}: couldn't list this machine's sessions (#{e.message})")
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "guardrails/path_resolve"
5
+ require_relative "memory_paths"
6
+ require_relative "paths"
7
+
8
+ module Samagotchi
9
+ # The shared decisions for `chi project init` and chi web's project picker.
10
+ module ProjectInit
11
+ Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message)
12
+
13
+ MARKER_TEXT = "# chi project marker: this folder is a chi project (docs/memory.md)\n"
14
+
15
+ module_function
16
+
17
+ def call(dir, home:)
18
+ dir = File.expand_path(dir.to_s)
19
+ found = MemoryPaths.lookup(dir, home: home)
20
+ if found.git?
21
+ message = "#{Paths.tilde(dir)} is in the git repository #{Paths.tilde(found.top)}: the repository already is " \
22
+ "the project, nothing written"
23
+ return Outcome.new(kind: :git, reason: nil, root: found.root, top: found.top, nested_in: nil, message: message)
24
+ end
25
+ if MemoryPaths.above_home?(dir, home: home)
26
+ message = "#{Paths.tilde(dir)} is $HOME, a folder above it or /: it can't be a chi project " \
27
+ "(every folder under it would be one); mark a folder inside it"
28
+ return Outcome.new(kind: :refused, reason: :above_home, root: found.root, top: found.top, nested_in: nil,
29
+ message: message)
30
+ end
31
+
32
+ # The marked project this folder sits in, if any: the folder itself (already marked) or its parent project.
33
+ enclosing = found.marker? ? found.root : nil
34
+ if enclosing == Guardrails::PathResolve.real(dir)
35
+ 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")
37
+ end
38
+
39
+ write_marker(dir)
40
+ root = File.realpath(dir)
41
+ # 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)
43
+ rescue SystemCallError => e
44
+ marker = File.join(dir, MemoryPaths::MARKER)
45
+ write_failed("couldn't write #{Paths.tilde(marker)}: #{e.message}")
46
+ end
47
+
48
+ # chi web opens a folder in a git repository without marking it, so the folder has to exist.
49
+ # @return [Outcome, nil] a refused (:write_failed) outcome when it can't be made, else nil
50
+ def ensure_folder(dir)
51
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
52
+ nil
53
+ rescue SystemCallError => e
54
+ write_failed("couldn't create #{Paths.tilde(dir)}: #{e.message}")
55
+ end
56
+
57
+ def write_failed(message)
58
+ Outcome.new(kind: :refused, reason: :write_failed, root: nil, top: nil, nested_in: nil, message: message)
59
+ end
60
+ private_class_method :write_failed
61
+
62
+ def write_marker(dir)
63
+ marker = File.join(dir, MemoryPaths::MARKER)
64
+ FileUtils.mkdir_p(File.dirname(marker))
65
+ File.write(marker, MARKER_TEXT)
66
+ end
67
+ private_class_method :write_marker
68
+ end
69
+ end
@@ -4,9 +4,10 @@ require_relative "memory_paths"
4
4
 
5
5
  module Samagotchi
6
6
  # Which project a folder belongs to, for scoping session lists (web and
7
- # terminal alike): the project root memories use (MemoryPaths.project_root),
8
- # so every worktree and subfolder of one repository is one project. A
9
- # folder in no git repository has no project (nil): its scope is "all".
7
+ # terminal alike): the project root memories use (MemoryPaths.lookup), so
8
+ # every worktree and subfolder of one repository, or every subfolder of a
9
+ # folder marked by `chi project init`, is one project. A folder in neither
10
+ # has no project (nil): its scope is "all".
10
11
  module ProjectScope
11
12
  module_function
12
13
 
@@ -17,7 +18,8 @@ module Samagotchi
17
18
  return nil if dir.empty?
18
19
  return cache[dir] if cache&.key?(dir)
19
20
 
20
- root = MemoryPaths.in_repo?(dir) ? MemoryPaths.project_root(File.expand_path(dir)) : nil
21
+ found = MemoryPaths.lookup(File.expand_path(dir))
22
+ root = found.project? ? found.root : nil
21
23
  cache[dir] = root if cache
22
24
  root
23
25
  end
@@ -19,9 +19,10 @@ module Samagotchi
19
19
  module_function
20
20
 
21
21
  # The AGENTS.md files a session in +cwd+ loads, as absolute paths,
22
- # outermost first: inside a git work tree every one from its top (a
23
- # linked worktree's own checkout) down to the cwd; outside a repository
24
- # only the cwd's ([] with none).
22
+ # outermost first: inside a project every one from its top
23
+ # (MemoryPaths.project_top: a git work tree's, a linked worktree's own
24
+ # checkout, or a folder marked by `chi project init`) down to the cwd;
25
+ # outside a project only the cwd's ([] with none).
25
26
  # @param cwd [String] where the prompt is built (Dir.pwd, not the
26
27
  # session's working directory: /tmp vs /private/tmp would change the
27
28
  # prompt bytes)
@@ -33,10 +34,10 @@ module Samagotchi
33
34
  # The sections of the AGENTS.md chain (#agents_paths) with text: the
34
35
  # first keeps the plain header (a one-file session's prompt is as it
35
36
  # always was), each further one names its directory relative to the
36
- # work tree top ("Project specific description for pkg/:").
37
+ # project's top ("Project specific description for pkg/:").
37
38
  # @return [Array<Section>]
38
39
  def agents_sections(cwd)
39
- top = MemoryPaths.work_tree_root(cwd)
40
+ top = MemoryPaths.project_top(cwd)
40
41
  sections = []
41
42
  agents_paths(cwd).each do |path|
42
43
  dir = File.dirname(path)
@@ -47,10 +48,12 @@ module Samagotchi
47
48
  sections
48
49
  end
49
50
 
50
- # The directories whose AGENTS.md a session in +cwd+ reads, outermost first.
51
+ # The directories whose AGENTS.md a session in +cwd+ reads, outermost
52
+ # first: from its project's top (the work tree's, or the marked root)
53
+ # down to +cwd+.
51
54
  def chain_dirs(cwd)
52
55
  cwd = File.expand_path(cwd)
53
- top = MemoryPaths.work_tree_root(cwd)
56
+ top = MemoryPaths.project_top(cwd)
54
57
  return [cwd] unless top
55
58
 
56
59
  rel = relative_dir(cwd, top)
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "steer"
4
- require_relative "bridge/card_places"
5
4
 
6
5
  module Samagotchi
7
6
  # Where a saved recap sits among the turns a page draws: after the last
8
7
  # turn opening it covers (Steer.turn_opening?, the rule its turns_since
9
8
  # count goes by). Its stamp is that opening's id, as a card's is
10
- # (Bridge::CardPlaces.stamp: a prompt's turn_id, a wake note's, a
11
- # /continue no reason line's opening_id), so the page finds the bubble by
9
+ # (Steer.opening_stamp: a prompt's turn_id, a wake note's, a /continue no
10
+ # reason line's opening_id), so the page finds the bubble by
12
11
  # id, not by counting back (a rollback or a check-in could make the count
13
12
  # land a turn off). Read at read time from recap.json's `covered`: the file
14
13
  # is unchanged, an old one gets a stamp too. Pure.
@@ -21,7 +20,7 @@ module Samagotchi
21
20
  # messages; nil for none, or one with no id (a line an older chi wrote)
22
21
  def opening_id(messages, covered)
23
22
  opening = Array(messages).first([covered.to_i, 0].max).reverse_each.find { |message| Steer.turn_opening?(message) }
24
- opening && Bridge::CardPlaces.stamp(opening)
23
+ opening && Steer.opening_stamp(opening)
25
24
  end
26
25
  end
27
26
  end
@@ -213,6 +213,7 @@ module Samagotchi
213
213
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
214
214
  chi models [--format json] [TEXT] the models --model takes, from every host
215
215
  chi home [set [--ruby PATH] | doctor] the Ruby every chi launch boots
216
+ chi project [init [DIR]] this folder's project; init makes a folder in no git repository one
216
217
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
217
218
  chi --remote NAME <send|answer|note|context|sessions|share|review> … those commands on that machine (or CHI_REMOTE=NAME)
218
219
  chi --remote NAME --attach ID | --resume ID | --dir DIR [-p TEXT] a session on that machine, in this terminal
@@ -81,6 +81,9 @@ module Samagotchi
81
81
  LIST = ->(value) { Array(value) }
82
82
  ID = ->(value) { value&.to_s }
83
83
  MESSAGES = ->(messages) { SessionField.scrub_utf8(messages.map { |msg| SessionField.stringify_keys(msg) }) }
84
+ # A text the user typed or pasted (a prompt, a title): the file gets it
85
+ # as valid UTF-8 (nil stays nil), the session keeps what it was given.
86
+ SCRUBBED = ->(value) { SessionField.scrub_utf8(value) }
84
87
  VERDICTS = lambda do |records|
85
88
  records&.map do |record|
86
89
  SessionField.scrub_utf8(record.to_h do |key, entry|
@@ -92,6 +95,10 @@ module Samagotchi
92
95
  origin = value.to_s
93
96
  TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
94
97
  end
98
+ TOOLKIT_ORIGIN_UNREAD = ->(value) { value if value.is_a?(String) && !TOOLKIT_ORIGINS.include?(value) }
99
+ # The kept value while there is one: the written one is the cast
100
+ # (never nil), so it can't come first as a level's does.
101
+ KEPT_FIRST = ->(written, kept) { kept || written }
95
102
  PARENT_ANSWERS = lambda do |value|
96
103
  case (level = Guardrails::ParentPolicy.parse_level(value))
97
104
  when :unset then nil
@@ -99,7 +106,8 @@ module Samagotchi
99
106
  else level
100
107
  end
101
108
  end
102
- private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :VERDICTS, :TOOLKIT_ORIGIN, :PARENT_ANSWERS
109
+ private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :SCRUBBED, :VERDICTS, :TOOLKIT_ORIGIN, :TOOLKIT_ORIGIN_UNREAD, :KEPT_FIRST,
110
+ :PARENT_ANSWERS
103
111
 
104
112
  # The fields of <id>.json, in the order #save writes them: key => its
105
113
  # SessionField (the value for a file without it, REQUIRED for the ones
@@ -141,12 +149,12 @@ module Samagotchi
141
149
  SessionField.plain("created_at", REQUIRED),
142
150
  SessionField.plain("updated_at", REQUIRED),
143
151
  SessionField.plain("status", STATUS_IDLE),
144
- SessionField.plain("last_prompt", ""),
145
- SessionField.plain("first_preview", ""),
152
+ SessionField.plain("last_prompt", "", write: SCRUBBED),
153
+ SessionField.plain("first_preview", "", write: SCRUBBED),
146
154
  # The name the user gave the session (/title, TitleCommand), else nil:
147
155
  # every list, card and tab shows it in place of #first_preview (see
148
156
  # #display_title). A continue's new link inherits it.
149
- SessionField.plain("title", nil),
157
+ SessionField.plain("title", nil, write: SCRUBBED),
150
158
  SessionField.plain("test_run", false, cast: BOOLEAN, write: BOOLEAN),
151
159
  # The question this session waits on (PendingQuestion), saved as
152
160
  # session.json's "pending_question"; nil with none. A Hash (a saved
@@ -225,7 +233,11 @@ module Samagotchi
225
233
  # Why this session is on its toolkit (TOOLKIT_ORIGINS; anything else
226
234
  # reads as chosen). Files from before the field read as chosen, so an
227
235
  # old default session is never newly suggested. Cast on every set too.
228
- SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN),
236
+ # An unknown String (a newer chi's origin) is written back as it was
237
+ # (the Unread rule), until the session sets its own: the kept value
238
+ # wins over the cast chosen.
239
+ SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN, unread: TOOLKIT_ORIGIN_UNREAD,
240
+ keep: KEPT_FIRST),
229
241
  # The two parts of #settings (Session.new's settings:, .from_h composes
230
242
  # them): its bundles part, then its core part.
231
243
  SessionField.file_only("bundle_settings", {}, from: :settings, write: ->(settings) { settings.bundles }),
@@ -272,16 +284,6 @@ module Samagotchi
272
284
  # Session.new's keywords: every FIELDS row's attr.
273
285
  ATTRS = FIELDS.values.filter_map(&:attr).freeze
274
286
 
275
- # @raise [ArgumentError] a keyword Session.new doesn't take, or a
276
- # required one missing (Ruby's own wording)
277
- def self.check_attrs!(attrs)
278
- unknown = attrs.keys - ATTRS
279
- raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
280
-
281
- missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
282
- raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
283
- end
284
-
285
287
  # The plain fields' readers and writers (each one's meaning is at its
286
288
  # FIELDS row): a writer takes the value as given (Session.new and
287
289
  # .from_h cast it), except the ones defined below.
@@ -362,6 +364,18 @@ module Samagotchi
362
364
  @toolkit_origin = load_field("toolkit_origin", value)
363
365
  end
364
366
 
367
+ # The user chose the toolkit (/toolkit, the toolkit binding's use): the
368
+ # origin is chosen. Whether that changes what the file holds, so the
369
+ # caller saves only then: a kept unknown origin (the Unread rule) reads
370
+ # as chosen but is written as it was, so it counts as a change.
371
+ # @return [Boolean]
372
+ def choose_toolkit_origin!
373
+ field = FIELDS.fetch("toolkit_origin")
374
+ was = field.dump(@toolkit_origin, @unread["toolkit_origin"])
375
+ self.toolkit_origin = "chosen"
376
+ was != field.dump(@toolkit_origin, @unread["toolkit_origin"])
377
+ end
378
+
365
379
  def parent_answers=(value)
366
380
  @parent_answers = load_field("parent_answers", value)
367
381
  end
@@ -446,7 +460,7 @@ module Samagotchi
446
460
  # (cast by its row, as the writers cast); +settings+ is the
447
461
  # settings pair's one value, +extra_fields+ the file's unknown keys.
448
462
  def initialize(extra_fields: {}, settings: nil, **attrs)
449
- self.class.check_attrs!(attrs)
463
+ check_attrs!(attrs)
450
464
  # What the level fields couldn't read, by key (SessionField.level).
451
465
  @unread = {}
452
466
  FIELDS.each_value do |field|
@@ -793,10 +807,21 @@ module Samagotchi
793
807
  def load_field(key, value)
794
808
  field = FIELDS.fetch(key)
795
809
  @unread[key] = field.unread.call(value) if field.unread
796
- field.load(value)
810
+ field.cast_value(value)
797
811
  end
798
812
  private :load_field
799
813
 
814
+ # @raise [ArgumentError] a keyword Session.new doesn't take, or a
815
+ # required one missing (Ruby's own wording)
816
+ def check_attrs!(attrs)
817
+ unknown = attrs.keys - ATTRS
818
+ raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
819
+
820
+ missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
821
+ raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
822
+ end
823
+ private :check_attrs!
824
+
800
825
  # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
801
826
  def checkpointed
802
827
  checkpoint = @turn_checkpoint
@@ -884,8 +909,9 @@ module Samagotchi
884
909
  # A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
885
910
  # A quoted or annotated message previews by what was typed under the
886
911
  # quote; a quote alone by its words, without the "> " markers or label.
912
+ # Bytes that aren't UTF-8 preview as ?, as the file saves them.
887
913
  def self.preview_of(text)
888
- norm = preview_words(text.to_s).gsub(/\s+/, " ").strip
914
+ norm = preview_words(SessionField.scrub_utf8(text.to_s)).gsub(/\s+/, " ").strip
889
915
  norm.length > 80 ? "#{norm[0, 80]}…" : norm
890
916
  end
891
917
 
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home project].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
@@ -15,10 +15,11 @@ module Samagotchi
15
15
  # block its lambdas call (`type.call`, not `yield`: never while the
16
16
  # constructor runs, so Performance/RedundantBlockCall is off for them).
17
17
  SessionField = Data.define(:key, :attr, :from, :default, :fresh, :optional, :cast, :write, :unread, :keep) do
18
- # A field read as given, or through +cast+.
19
- def self.plain(key, default, cast: nil, write: nil, fresh: default, optional: false)
18
+ # A field read as given, or through +cast+; +unread+ and +keep+ give
19
+ # it the Unread rule (see .level).
20
+ def self.plain(key, default, cast: nil, write: nil, fresh: default, optional: false, unread: nil, keep: nil)
20
21
  new(key: key, attr: key.to_sym, from: nil, default: default, fresh: fresh, optional: optional, cast: cast,
21
- write: write, unread: nil, keep: nil)
22
+ write: write, unread: unread, keep: keep)
22
23
  end
23
24
 
24
25
  # rubocop:disable Performance/RedundantBlockCall
@@ -98,7 +99,7 @@ module Samagotchi
98
99
  def ivar = :"@#{attr || from}"
99
100
 
100
101
  # +value+ as the attribute holds it.
101
- def load(value) = cast ? cast.call(value) : value
102
+ def cast_value(value) = cast ? cast.call(value) : value
102
103
 
103
104
  # The attribute's +value+ as the file holds it, with what the session
104
105
  # kept of a value it couldn't read (+kept+, .level's).
@@ -364,6 +364,16 @@ module Samagotchi
364
364
  turn_prompt?(message) && !shell_line?(message)
365
365
  end
366
366
 
367
+ # An opening's stamp: its turn_id (a prompt's, a wake note's), else its
368
+ # own opening_id (/continue no's reason line); nil for neither (a line
369
+ # an older chi wrote). Where a card (Bridge::CardPlaces) and the recap
370
+ # (RecapPlace) sit.
371
+ # @return [String, nil]
372
+ def opening_stamp(message)
373
+ id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
374
+ id.is_a?(String) && !id.empty? ? id : nil
375
+ end
376
+
367
377
  # Call a loop's drain: +at_answer+ goes only to a drain that takes it
368
378
  # (the Engine's); a caller's own drain (a queue's #drain) is called bare.
369
379
  # A failing drain drains nothing (the loop keeps going).
@@ -291,7 +291,7 @@ module Samagotchi
291
291
  6. Use write for full-file rewrites or creating new files.
292
292
 
293
293
  Memory convention:
294
- Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder" line below)
294
+ Project scope: one folder per project (a git repository, shared by its worktrees and subdirectories, or a folder marked by `chi project init` and its subfolders; its path is on the "Project memories folder" line below)
295
295
  System scope: ~/.config/samagotchi/memories/ (cross-project)
296
296
  memory_read accepts optional scope (project|system).
297
297
  memory_write requires explicit scope and entry name.
@@ -623,10 +623,20 @@ module Samagotchi
623
623
  File.expand_path(path)
624
624
  end
625
625
 
626
+ # The project_location line over its top, for a checkout (git) or a
627
+ # marked project: [below the top, at the top].
628
+ TOP_LINES = {
629
+ git: ["Top of this checkout (where the project's commands run from; stay inside it):",
630
+ "This checkout (the project's files; its commands run here):"],
631
+ marker: ["Top of this project (where its commands run from; stay inside it):",
632
+ "This project (its files; its commands run here):"]
633
+ }.freeze
634
+
626
635
  # Where the session runs and which project memory folder it uses. Only
627
- # this checkout is named: its top (at its root too, so a model doesn't
628
- # take the memories folder for the project and cd there, Qwen
629
- # 2026-10-06), and in a linked worktree a line that work stays in it. The repository's main
636
+ # this checkout (or marked project, MemoryPaths.lookup) is named: its
637
+ # top (at its root too, so a model doesn't take the memories folder for
638
+ # the project and cd there, Qwen 2026-10-06), and in a linked worktree
639
+ # a line that work stays in it. The repository's main
630
640
  # checkout (MemoryPaths.project_root, which scopes memories and session
631
641
  # lists) is never shown: a model told its path went to work there
632
642
  # (a worktree child committed onto main, 2026-10-03 and 2026-10-06).
@@ -635,16 +645,15 @@ module Samagotchi
635
645
  def project_location
636
646
  Degrade.call(:prompt, "location_failed") do
637
647
  cwd = Dir.pwd
638
- top = MemoryPaths.work_tree_root(cwd)
648
+ found = MemoryPaths.lookup(cwd)
649
+ top = found.top
639
650
  lines = ["Current working directory:", cwd]
640
- if top && top != cwd
641
- lines << "Top of this checkout (where the project's commands run from; stay inside it):"
642
- lines << top
643
- elsif top
644
- lines << "This checkout (the project's files; its commands run here):"
651
+ if top
652
+ below, at = TOP_LINES.fetch(found.kind)
653
+ lines << (top == cwd ? at : below)
645
654
  lines << top
646
655
  end
647
- if top && MemoryPaths.project_root(cwd) != top
656
+ if found.git? && found.root != top
648
657
  lines << "This checkout is a linked git worktree: read, edit, run and commit only in it, never in the " \
649
658
  "repository's other checkouts."
650
659
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../bridge/turn_accumulator"
4
+ require_relative "../bridge/turn_drawings"
4
5
  require_relative "../context_note"
5
6
  require_relative "../image_store"
6
7
  require_relative "../log"
@@ -163,13 +164,17 @@ module Samagotchi
163
164
 
164
165
  # The snapshot's drawing as [turn, end event] when it is +prompt+'s
165
166
  # turn (a Continue's or a reminder's drawing isn't the prompt's: the
166
- # rows are rebuilt), else nil.
167
+ # rows are rebuilt), else nil; nil too for one whose parts aren't a
168
+ # list of rows.
167
169
  def drawn_turn(drawing, prompt)
168
170
  return nil unless drawing.is_a?(Hash) && drawing[:drawing].is_a?(Hash) && drawing[:end].is_a?(Hash)
169
171
 
170
172
  turn = Bridge::TurnAccumulator.from_wire(drawing[:drawing])
171
173
  id = prompt[:turn_id] || prompt["turn_id"]
172
174
  return nil unless id && turn[:turn_id].to_s == id.to_s
175
+ # An older worker sends a line its parse didn't check (the unfold
176
+ # would raise after the prompt line is drawn).
177
+ return nil unless Bridge::TurnDrawings.rows?(turn[:parts])
173
178
 
174
179
  [turn, EventRenderer.symbolize(Bridge::TurnAccumulator.from_wire(drawing[:end]))]
175
180
  end