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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +125 -1
- data/docs/architecture.md +12 -9
- data/docs/cli.md +7 -5
- data/docs/context.md +10 -1
- data/docs/guardrails.md +33 -5
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +28 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/engine.md +7 -1
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +43 -13
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +53 -8
- data/docs/sessions.md +17 -12
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +103 -44
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +57 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +6 -0
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +10 -4
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +25 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +93 -23
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
- data/lib/samagotchi/memory_bundle/status.rb +3 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
- data/lib/samagotchi/memory_orphans.rb +54 -13
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +374 -76
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +59 -7
- data/lib/samagotchi/session_access/local.rb +53 -22
- data/lib/samagotchi/session_access/remote.rb +13 -5
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +20 -4
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +162 -24
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +167 -100
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +129 -0
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +19 -6
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +9 -4
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -5
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +15 -11
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +61 -11
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +62 -10
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- 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
|
|
27
|
-
folder and group their sessions. Not in a git
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
142
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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?
|