samagotchi 0.159.0 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/docs/architecture.md +5 -6
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +21 -2
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +7 -0
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +41 -13
- data/docs/scheduling.md +16 -3
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -2
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/client_id.rb +4 -0
- data/lib/samagotchi/command_intake.rb +56 -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_command.rb +9 -3
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +13 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- 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/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +27 -6
- 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/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +14 -11
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/status.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +2 -0
- data/lib/samagotchi/memory_orphans.rb +14 -4
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- 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/schedules.rb +99 -39
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/local.rb +8 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +4 -1
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +10 -6
- data/lib/samagotchi/session_manager/spawn.rb +9 -2
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/update_command.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -4
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/worker.rb +18 -6
- data/lib/samagotchi/worker_inbound.rb +21 -2
- metadata +5 -1
|
@@ -52,7 +52,8 @@ module Samagotchi
|
|
|
52
52
|
# mismatch warnings. merged and replaced are file
|
|
53
53
|
# names, behind FileStanding::Behind; under plan they are what apply would
|
|
54
54
|
# merge, replace or leave behind.
|
|
55
|
-
#
|
|
55
|
+
# added_files is the new version's memory files the bundle didn't own
|
|
56
|
+
# before; adds is nil but for a profile: the members it installs.
|
|
56
57
|
# kept_own is nil but for a profile that installed a member: its
|
|
57
58
|
# Profile.kept_own_line lines.
|
|
58
59
|
# trash_copy is nil but under apply when it saved edited files
|
|
@@ -62,8 +63,8 @@ module Samagotchi
|
|
|
62
63
|
# manifest: what a row at the shipped version says of its manifest.yml
|
|
63
64
|
# (ManifestStanding.keep's line: would keep or kept it, or why not and
|
|
64
65
|
# the command), nil when nothing.
|
|
65
|
-
Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :
|
|
66
|
-
:trash_copy, :earlier, :manifest, keyword_init: true) do
|
|
66
|
+
Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :added_files, :adds,
|
|
67
|
+
:kept_own, :trash_copy, :earlier, :manifest, keyword_init: true) do
|
|
67
68
|
def kept = Array(behind).map(&:file)
|
|
68
69
|
end
|
|
69
70
|
|
|
@@ -208,6 +209,7 @@ module Samagotchi
|
|
|
208
209
|
return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
|
|
209
210
|
return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
|
|
210
211
|
|
|
212
|
+
row.added_files = (manifest.files.keys - bundle.files.keys).sort
|
|
211
213
|
forecast = forecast(row, bundle)
|
|
212
214
|
row.merged = forecast.merged
|
|
213
215
|
row.behind = forecast.behind
|
|
@@ -84,12 +84,12 @@ module Samagotchi
|
|
|
84
84
|
private_class_method :digest_or_nil
|
|
85
85
|
|
|
86
86
|
# The class-file hooks the record still has (a 0.157 install's; chi
|
|
87
|
-
# loads none, Manifest::HooksRetired), each "
|
|
87
|
+
# loads none, Manifest::HooksRetired), each "present" or "missing" in
|
|
88
88
|
# hooks/, by name; {} for none.
|
|
89
89
|
# @return [Hash{String => String}]
|
|
90
90
|
def self.retired_hooks(provenance, bundle)
|
|
91
91
|
bundle.hooks.keys.sort.to_h do |file|
|
|
92
|
-
[file, File.exist?(File.join(provenance.hooks_dir, file)) ? "
|
|
92
|
+
[file, File.exist?(File.join(provenance.hooks_dir, file)) ? "present" : "missing"]
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
@@ -56,6 +56,8 @@ module Samagotchi
|
|
|
56
56
|
# No file list, just remove provenance (rules, a plugin and scripts
|
|
57
57
|
# live there too)
|
|
58
58
|
save_edits(provenance, bundle)
|
|
59
|
+
plugin_path = provenance.plugin_path(bundle)
|
|
60
|
+
@removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
|
|
59
61
|
FileUtils.rm_rf(provenance.bundle_dir)
|
|
60
62
|
return true
|
|
61
63
|
end
|
|
@@ -6,7 +6,13 @@ module Samagotchi
|
|
|
6
6
|
# A project memory folder left behind by a move: memories copied to a
|
|
7
7
|
# machine (or a path) where the project root differs, so its key
|
|
8
8
|
# (MemoryPaths.project_key: basename + MD5 of the root) differs too, and
|
|
9
|
-
# chi finds no project memories without a word about why.
|
|
9
|
+
# chi finds no project memories without a word about why. A marked
|
|
10
|
+
# project with an id is keyed by the id (`<basename>-<8 hex>`, which the
|
|
11
|
+
# pattern never takes for a path key): it is asked about only while its
|
|
12
|
+
# id folder holds no *.md, like any other (memories moved before it had
|
|
13
|
+
# an id). An id folder is a candidate itself for a project keyed by path
|
|
14
|
+
# with the same basename: a marked folder that `git init` made a
|
|
15
|
+
# repository.
|
|
10
16
|
#
|
|
11
17
|
# Pure: one listing of the projects dir, no git subprocess. Said at
|
|
12
18
|
# session start (Engine#announce_load_events!, label "memory") and under a
|
|
@@ -33,10 +39,14 @@ module Samagotchi
|
|
|
33
39
|
return nil if File.directory?(computed) && Dir.children(computed).any? { |name| name.end_with?(".md") }
|
|
34
40
|
|
|
35
41
|
key = File.basename(computed)
|
|
36
|
-
# The root's basename, never a split of a key on "_" (a basename may hold one)
|
|
37
|
-
|
|
42
|
+
# The root's basename, never a split of a key on "_" (a basename may hold one): another path's key
|
|
43
|
+
# (<basename>_<8 hex>), or a marked project's id folder (<basename>-<8 hex>, ProjectId) a `git init`
|
|
44
|
+
# in the marked folder (git wins: keyed by its path again) left behind.
|
|
45
|
+
pattern = /\A#{Regexp.escape(File.basename(MemoryPaths.project_root(cwd)))}[_-]\h{8}\z/
|
|
38
46
|
names = Dir.children(projects).select { |name| name != key && name.match?(pattern) }
|
|
39
|
-
|
|
47
|
+
# A link is an adopted project's old name (ProjectInit.adopt), never an orphan.
|
|
48
|
+
candidates = names.sort.map { |name| File.join(projects, name) }
|
|
49
|
+
.select { |path| File.directory?(path) && !File.symlink?(path) }
|
|
40
50
|
return nil if candidates.empty?
|
|
41
51
|
|
|
42
52
|
Found.new(computed: computed, candidates: candidates, computed_exists: File.directory?(computed))
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "guardrails/path_resolve"
|
|
6
|
+
require_relative "project_id"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
|
|
@@ -23,8 +24,12 @@ module Samagotchi
|
|
|
23
24
|
# :none; +root+ is what memories are keyed on (#project_root: git's
|
|
24
25
|
# common-dir checkout, the marked folder's realpath, or the folder
|
|
25
26
|
# itself); +top+ is the folder a session reads its AGENTS.md chain from
|
|
26
|
-
# (the work tree's top, the marked root; nil for :none)
|
|
27
|
-
|
|
27
|
+
# (the work tree's top, the marked root; nil for :none); +id+ is the
|
|
28
|
+
# marker's id (ProjectId), set only for kind :marker with a valid one
|
|
29
|
+
# (a git repository, and a marker #lookup ignores, never give one).
|
|
30
|
+
ProjectLookup = Data.define(:kind, :root, :top, :id) do
|
|
31
|
+
def initialize(kind:, root:, top:, id: nil) = super
|
|
32
|
+
|
|
28
33
|
def project? = kind != :none
|
|
29
34
|
def git? = kind == :git
|
|
30
35
|
def marker? = kind == :marker
|
|
@@ -47,7 +52,7 @@ module Samagotchi
|
|
|
47
52
|
return ProjectLookup.new(kind: :git, root: git_root(dot_git), top: File.dirname(dot_git)) if dot_git
|
|
48
53
|
|
|
49
54
|
marked = nearest_marker(cwd, home: home)
|
|
50
|
-
return ProjectLookup.new(kind: :marker, root: marked, top: marked) if marked
|
|
55
|
+
return ProjectLookup.new(kind: :marker, root: marked, top: marked, id: marker_id(marked)) if marked
|
|
51
56
|
|
|
52
57
|
ProjectLookup.new(kind: :none, root: cwd, top: nil)
|
|
53
58
|
end
|
|
@@ -73,12 +78,30 @@ module Samagotchi
|
|
|
73
78
|
File.join(system_dir(env: env), "projects")
|
|
74
79
|
end
|
|
75
80
|
|
|
76
|
-
# One folder per project
|
|
77
|
-
|
|
81
|
+
# One folder per project: a marked project's id (ProjectId), else its
|
|
82
|
+
# root's path key (#key_for_root).
|
|
83
|
+
def project_key(cwd = Dir.pwd)
|
|
84
|
+
found = lookup(cwd)
|
|
85
|
+
key_for(root: found.root, id: found.id)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The key of a project already resolved: +id+ (a ProjectLookup's, a
|
|
89
|
+
# session's project_id) when there is one, else +root+'s path key. Every
|
|
90
|
+
# caller holding a root keys here, with the id that came with it.
|
|
91
|
+
def key_for(root:, id:) = id || key_for_root(root)
|
|
78
92
|
|
|
79
|
-
#
|
|
93
|
+
# The path key: basename + 8 hex chars of MD5(full root path). Pure: it
|
|
94
|
+
# reads no file (#key_for adds the id).
|
|
80
95
|
def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
|
|
81
96
|
|
|
97
|
+
# The id in the marker at +root+, or nil (none, or not a valid one: one
|
|
98
|
+
# debug line says why).
|
|
99
|
+
def marker_id(root)
|
|
100
|
+
read = ProjectId.read(root)
|
|
101
|
+
ProjectId.log_problem(root, read)
|
|
102
|
+
read.id
|
|
103
|
+
end
|
|
104
|
+
|
|
82
105
|
# The project root (#lookup): in a repository, its common git directory
|
|
83
106
|
# identifies the project; every linked worktree shares it. Returns the
|
|
84
107
|
# checkout holding it when it is named .git, otherwise the git dir itself
|
|
@@ -124,7 +124,11 @@ module Samagotchi
|
|
|
124
124
|
# The project the report is for, as MemoryPaths keys it (a worktree
|
|
125
125
|
# resolves to its repository's root; both spellings of a path, /var and
|
|
126
126
|
# /private/var, mean the same project).
|
|
127
|
-
|
|
127
|
+
found = MemoryPaths.lookup(File.expand_path(root.to_s))
|
|
128
|
+
@root = expand(found.root)
|
|
129
|
+
# A marked project's id: its sessions from before a move (another
|
|
130
|
+
# stored root, the same id) are its sessions too.
|
|
131
|
+
@project_id = found.id
|
|
128
132
|
@env = env
|
|
129
133
|
@chars_per_token = chars_per_token.positive? ? chars_per_token.to_f : 4.0
|
|
130
134
|
@warn_limit = warn_limit.to_i
|
|
@@ -331,23 +335,30 @@ module Samagotchi
|
|
|
331
335
|
File.expand_path(dir)
|
|
332
336
|
end
|
|
333
337
|
|
|
334
|
-
# The project a session belongs to: the root
|
|
335
|
-
# from before the field, or a folder in no
|
|
336
|
-
# folder it works in.
|
|
337
|
-
def
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
338
|
+
# The project a session belongs to, [root, id]: the root (and id) its
|
|
339
|
+
# file stores, or (a file from before the field, or a folder in no
|
|
340
|
+
# repository) the lookup of the folder it works in; [nil, nil] for none.
|
|
341
|
+
def session_project(session)
|
|
342
|
+
if session.project_root
|
|
343
|
+
[expand(session.project_root), session.project_id]
|
|
344
|
+
elsif !session.working_directory.to_s.empty?
|
|
345
|
+
found = MemoryPaths.lookup(File.expand_path(session.working_directory.to_s))
|
|
346
|
+
[expand(found.root), found.id]
|
|
347
|
+
else
|
|
348
|
+
[nil, nil]
|
|
349
|
+
end
|
|
341
350
|
end
|
|
342
351
|
|
|
343
352
|
def project_dir_of(session)
|
|
344
|
-
root =
|
|
345
|
-
root ? File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.
|
|
353
|
+
root, id = session_project(session)
|
|
354
|
+
root ? File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for(root: root, id: id)) : nil
|
|
346
355
|
end
|
|
347
356
|
|
|
348
|
-
# A session of this folder's project (worktrees of one repository share
|
|
357
|
+
# A session of this folder's project (worktrees of one repository share
|
|
358
|
+
# it; a marked project's sessions from before a move share its id).
|
|
349
359
|
def project_session?(session)
|
|
350
|
-
|
|
360
|
+
root, id = session_project(session)
|
|
361
|
+
root == @root || (!@project_id.nil? && id == @project_id)
|
|
351
362
|
end
|
|
352
363
|
|
|
353
364
|
# "system/delegated" is the memory named delegated.
|
|
@@ -152,7 +152,8 @@ module Samagotchi
|
|
|
152
152
|
return [] unless id && Session.exist?(id, state_dir: state_dir)
|
|
153
153
|
|
|
154
154
|
session = Session.load(id, state_dir: state_dir)
|
|
155
|
-
ContextSources.attached(id, project_root: session.project_root,
|
|
155
|
+
ContextSources.attached(id, project_root: session.project_root, project_id: session.project_id,
|
|
156
|
+
state_dir: state_dir).map do |attached|
|
|
156
157
|
snapshot = attached.snapshot
|
|
157
158
|
{ name: attached.name, scope: attached.location.scope, why: attached.source.why,
|
|
158
159
|
hint: snapshot.hint || attached.source.hint, fetched_at: snapshot.fetched_at, error: snapshot.error,
|
|
@@ -192,6 +193,7 @@ module Samagotchi
|
|
|
192
193
|
|
|
193
194
|
def attached?(candidate, session)
|
|
194
195
|
ContextSources.attached(session&.id || @host.session_id.call, project_root: session&.project_root,
|
|
196
|
+
project_id: session&.project_id,
|
|
195
197
|
state_dir: state_dir, shadowed: true)
|
|
196
198
|
.any? { |a| a.name == candidate.name || (candidate.url && a.source.hint == candidate.url) }
|
|
197
199
|
end
|
|
@@ -71,7 +71,10 @@ module Samagotchi
|
|
|
71
71
|
prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
|
|
72
72
|
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, preview: title,
|
|
73
73
|
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
|
|
74
|
-
setup: @host.setup&.call || SessionSetup.new, before_spawn: before_spawn
|
|
74
|
+
setup: @host.setup&.call || SessionSetup.new, before_spawn: before_spawn,
|
|
75
|
+
# The first prompt is the plugin's, as #send's is: no command of the
|
|
76
|
+
# owner's (CommandIntake), a ! line refused.
|
|
77
|
+
client_id: ClientId::PLUGIN
|
|
75
78
|
)
|
|
76
79
|
child.id
|
|
77
80
|
rescue *REFUSED => e
|
|
@@ -19,33 +19,40 @@ module Samagotchi
|
|
|
19
19
|
include CLI::Command
|
|
20
20
|
|
|
21
21
|
USAGE = <<~TEXT
|
|
22
|
-
Usage: chi project [init [DIR]]
|
|
22
|
+
Usage: chi project [init [--new-id] [DIR]]
|
|
23
23
|
(none) this folder's project: how it was found (git, marker or none), its root, its
|
|
24
|
-
top and its project memories folder
|
|
24
|
+
top, its id and its project memories folder
|
|
25
25
|
init [DIR] make DIR (default: this folder; created when missing) a chi project: writes
|
|
26
|
-
DIR/#{MemoryPaths::MARKER}, so DIR and every subfolder share one project
|
|
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
|