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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -1
- data/README.md +1 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -6
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +21 -17
- data/docs/configuration.md +7 -5
- data/docs/context.md +5 -3
- data/docs/guardrails.md +8 -7
- data/docs/hooks.md +44 -80
- data/docs/internals/bridge.md +1 -1
- data/docs/internals/bundles.md +47 -34
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +39 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/sessions.md +5 -5
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +15 -43
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +39 -119
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +30 -35
- data/lib/samagotchi/memory_bundle/status.rb +12 -10
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/delegate.rb +12 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- metadata +3 -2
- 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.
|
|
8
|
-
#
|
|
9
|
-
# folder
|
|
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
|
-
|
|
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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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
|
-
#
|
|
37
|
+
# project's top ("Project specific description for pkg/:").
|
|
37
38
|
# @return [Array<Section>]
|
|
38
39
|
def agents_sections(cwd)
|
|
39
|
-
top = MemoryPaths.
|
|
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
|
|
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.
|
|
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
|
-
# (
|
|
11
|
-
#
|
|
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 &&
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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, :
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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).
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -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
|
|
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
|
|
628
|
-
#
|
|
629
|
-
# 2026-10-06), and in a linked worktree
|
|
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
|
-
|
|
648
|
+
found = MemoryPaths.lookup(cwd)
|
|
649
|
+
top = found.top
|
|
639
650
|
lines = ["Current working directory:", cwd]
|
|
640
|
-
if top
|
|
641
|
-
|
|
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
|
|
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
|