samagotchi 0.158.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 +60 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -7
- data/docs/cli.md +57 -2
- data/docs/configuration.md +9 -0
- data/docs/context.md +7 -1
- data/docs/guardrails.md +24 -3
- data/docs/hooks.md +3 -0
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +9 -7
- data/docs/internals/engine.md +7 -1
- data/docs/memory.md +46 -14
- data/docs/scheduling.md +241 -0
- data/docs/sessions.md +28 -2
- data/lib/samagotchi/bridge.rb +37 -4
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/bundle_command.rb +39 -1
- 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 +17 -3
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/config.rb +12 -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_move.rb +74 -0
- 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/guardrails/pair_boundary.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
- data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
- 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 +22 -15
- data/lib/samagotchi/memory_bundle/status.rb +22 -3
- data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
- 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/run_options.rb +1 -0
- data/lib/samagotchi/schedule_command.rb +252 -0
- data/lib/samagotchi/schedules.rb +1008 -0
- data/lib/samagotchi/session.rb +17 -2
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +28 -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 +34 -13
- data/lib/samagotchi/session_manager/worker_command.rb +11 -23
- data/lib/samagotchi/state_format.rb +118 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +43 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -0
- data/lib/samagotchi/tools/builtins.rb +17 -0
- data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +10 -2
- data/lib/samagotchi/tools/list_schedules.rb +43 -0
- data/lib/samagotchi/tools/schedule_task.rb +39 -0
- 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/public/chains.js +32 -8
- data/lib/samagotchi/web/public/index.html +4 -1
- data/lib/samagotchi/web/public/info_bar.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_hub.rb +20 -0
- data/lib/samagotchi/worker.rb +42 -12
- data/lib/samagotchi/worker_boot.rb +3 -4
- data/lib/samagotchi/worker_inbound.rb +44 -2
- data/lib/samagotchi/worker_link.rb +5 -0
- data/lib/samagotchi/worker_wakes.rb +9 -1
- metadata +12 -1
|
@@ -83,6 +83,12 @@ module Samagotchi
|
|
|
83
83
|
|
|
84
84
|
# The source's hooks/*.rb, relative and sorted ([] for none).
|
|
85
85
|
def self.files_in(dir) = Dir.glob(File.join(dir, "hooks", "*.rb")).map { |f| "hooks/#{File.basename(f)}" }.sort
|
|
86
|
+
|
|
87
|
+
# Refuse a source's class-file hooks before parsing its manifest.
|
|
88
|
+
def self.refuse_source_files(dir)
|
|
89
|
+
files = files_in(dir)
|
|
90
|
+
raise for_files(files) unless files.empty?
|
|
91
|
+
end
|
|
86
92
|
end
|
|
87
93
|
|
|
88
94
|
# A plugin file is a plain .rb name in the bundle's top directory.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "asset_installer"
|
|
4
|
+
require_relative "asset_state"
|
|
4
5
|
require_relative "installer"
|
|
5
6
|
require_relative "listing"
|
|
6
7
|
require_relative "manifest"
|
|
@@ -28,10 +29,11 @@ module Samagotchi
|
|
|
28
29
|
#
|
|
29
30
|
# .md files take the Installer's 3-way merge: a clean text merge replaces
|
|
30
31
|
# the file (merged), a real conflict keeps the user's (behind). Rules,
|
|
31
|
-
# the plugin and scripts are replaced, an older chi's hooks removed; an
|
|
32
|
-
#
|
|
33
|
-
# the
|
|
34
|
-
# replaced
|
|
32
|
+
# the plugin and scripts are replaced, an older chi's hooks removed; an
|
|
33
|
+
# edited one (AssetState#edited?, Trash.edit?'s rule: its bytes neither
|
|
34
|
+
# the recorded sha, none recorded counting as edited, nor the incoming
|
|
35
|
+
# file's) is copied to the trash first and listed in replaced
|
|
36
|
+
# (#trash_bound).
|
|
35
37
|
#
|
|
36
38
|
# A profile (a shipped meta bundle) is updated through Profile: its row
|
|
37
39
|
# is :would_update when the shipped version is newer or it has members
|
|
@@ -50,7 +52,8 @@ module Samagotchi
|
|
|
50
52
|
# mismatch warnings. merged and replaced are file
|
|
51
53
|
# names, behind FileStanding::Behind; under plan they are what apply would
|
|
52
54
|
# merge, replace or leave behind.
|
|
53
|
-
#
|
|
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.
|
|
54
57
|
# kept_own is nil but for a profile that installed a member: its
|
|
55
58
|
# Profile.kept_own_line lines.
|
|
56
59
|
# trash_copy is nil but under apply when it saved edited files
|
|
@@ -60,8 +63,8 @@ module Samagotchi
|
|
|
60
63
|
# manifest: what a row at the shipped version says of its manifest.yml
|
|
61
64
|
# (ManifestStanding.keep's line: would keep or kept it, or why not and
|
|
62
65
|
# the command), nil when nothing.
|
|
63
|
-
Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :
|
|
64
|
-
: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
|
|
65
68
|
def kept = Array(behind).map(&:file)
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -206,6 +209,7 @@ module Samagotchi
|
|
|
206
209
|
return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
|
|
207
210
|
return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
|
|
208
211
|
|
|
212
|
+
row.added_files = (manifest.files.keys - bundle.files.keys).sort
|
|
209
213
|
forecast = forecast(row, bundle)
|
|
210
214
|
row.merged = forecast.merged
|
|
211
215
|
row.behind = forecast.behind
|
|
@@ -293,21 +297,24 @@ module Samagotchi
|
|
|
293
297
|
end
|
|
294
298
|
|
|
295
299
|
# What the upgrade from +source_dir+ copies to the trash, as
|
|
296
|
-
# hooks/F, guardrails/F, plugin/F, scripts/F
|
|
297
|
-
# folders AssetInstaller replaces
|
|
298
|
-
# it puts in its place
|
|
299
|
-
#
|
|
300
|
-
# upgrade removes whole (AssetInstaller#retire_hooks; no
|
|
300
|
+
# hooks/F, guardrails/F, plugin/F, scripts/F. Rules, the plugin and
|
|
301
|
+
# scripts: each file of the folders AssetInstaller replaces, against the
|
|
302
|
+
# one it puts in its place, that is AssetState#edited? (the upgrade's
|
|
303
|
+
# save_first: the same files, the same rule). hooks/: an older chi's,
|
|
304
|
+
# which the upgrade removes whole (AssetInstaller#retire_hooks; no
|
|
305
|
+
# incoming): Trash.edits over it.
|
|
301
306
|
def trash_bound(name, bundle, manifest, source_dir)
|
|
302
307
|
provenance = Provenance.new(name: name)
|
|
303
308
|
rules = Dir.glob(File.join(source_dir, "guardrails", "*.yml")).sort
|
|
304
309
|
.reject { |file| AssetInstaller.linked?(file, source_dir) }
|
|
305
310
|
plugin = manifest.plugin ? { manifest.plugin[:file] => File.join(source_dir, manifest.plugin[:file]) } : {}
|
|
306
311
|
scripts = manifest.scripts.keys.to_h { |file| [file, File.join(source_dir, "scripts", file)] }
|
|
312
|
+
assets = AssetState.in(provenance.guardrails_dir, kind_of_file: :rule, recorded: bundle.guardrails,
|
|
313
|
+
incoming: rules.to_h { |file| [File.basename(file), file] }) +
|
|
314
|
+
AssetState.in(provenance.plugin_dir, kind_of_file: :plugin, recorded: bundle.plugin_shas, incoming: plugin) +
|
|
315
|
+
AssetState.in(provenance.scripts_dir, kind_of_file: :script, recorded: bundle.scripts, incoming: scripts)
|
|
307
316
|
Trash.edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256)) +
|
|
308
|
-
|
|
309
|
-
Trash.edits(provenance.plugin_dir, "plugin", bundle.plugin_shas, plugin) +
|
|
310
|
-
Trash.edits(provenance.scripts_dir, "scripts", bundle.scripts, scripts)
|
|
317
|
+
assets.select(&:edited?).map(&:label)
|
|
311
318
|
end
|
|
312
319
|
|
|
313
320
|
# Installed rules and the plugin whose sha differs from the recorded
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "file_digest"
|
|
4
4
|
require_relative "provenance"
|
|
5
5
|
require_relative "file_state"
|
|
6
|
+
require_relative "asset_state"
|
|
6
7
|
require_relative "manifest"
|
|
7
8
|
require_relative "../version"
|
|
8
9
|
require_relative "index_updater"
|
|
@@ -23,8 +24,10 @@ module Samagotchi
|
|
|
23
24
|
# paths at all). An entry's behind is its FileStanding::Behind, nil for a
|
|
24
25
|
# file that isn't; merge_note its FileStanding::MergeNote, nil without one;
|
|
25
26
|
# unreadable: the file is there, but this chi can't read it.
|
|
27
|
+
# rules and scripts: each recorded one by name, "ok", "modified" or
|
|
28
|
+
# "missing" (asset_states).
|
|
26
29
|
Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
|
|
27
|
-
:retired_hooks, :needs)
|
|
30
|
+
:rules, :scripts, :retired_hooks, :needs)
|
|
28
31
|
|
|
29
32
|
# A scope this chi can't resolve (a newer chi's, a hand edit) isn't
|
|
30
33
|
# guessed: scope_error says so, target_dir is nil and each file is
|
|
@@ -70,6 +73,8 @@ module Samagotchi
|
|
|
70
73
|
end
|
|
71
74
|
Report.new(provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
|
|
72
75
|
target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
|
|
76
|
+
rules: asset_states(provenance.guardrails_dir, :rule, bundle.guardrails),
|
|
77
|
+
scripts: asset_states(provenance.scripts_dir, :script, bundle.scripts),
|
|
73
78
|
retired_hooks: retired_hooks(provenance, bundle), needs: needs_status(bundle))
|
|
74
79
|
end
|
|
75
80
|
|
|
@@ -79,12 +84,26 @@ module Samagotchi
|
|
|
79
84
|
private_class_method :digest_or_nil
|
|
80
85
|
|
|
81
86
|
# The class-file hooks the record still has (a 0.157 install's; chi
|
|
82
|
-
# loads none, Manifest::HooksRetired), each "
|
|
87
|
+
# loads none, Manifest::HooksRetired), each "present" or "missing" in
|
|
83
88
|
# hooks/, by name; {} for none.
|
|
84
89
|
# @return [Hash{String => String}]
|
|
85
90
|
def self.retired_hooks(provenance, bundle)
|
|
86
91
|
bundle.hooks.keys.sort.to_h do |file|
|
|
87
|
-
[file, File.exist?(File.join(provenance.hooks_dir, file)) ? "
|
|
92
|
+
[file, File.exist?(File.join(provenance.hooks_dir, file)) ? "present" : "missing"]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Each recorded rule or script: "missing" (not there), "modified" (its
|
|
97
|
+
# AssetState is edited: the next install replaces it, a copy kept in
|
|
98
|
+
# the trash; an edited rule doesn't load) or "ok"; {} for none.
|
|
99
|
+
# @return [Hash{String => String}]
|
|
100
|
+
def self.asset_states(dir, kind_of_file, recorded)
|
|
101
|
+
recorded.keys.sort.to_h do |name|
|
|
102
|
+
asset = AssetState.of(File.join(dir, name), kind_of_file: kind_of_file, recorded: recorded[name], name: name)
|
|
103
|
+
[name, if asset.state == :missing then "missing"
|
|
104
|
+
elsif asset.edited? then "modified"
|
|
105
|
+
else "ok"
|
|
106
|
+
end]
|
|
88
107
|
end
|
|
89
108
|
end
|
|
90
109
|
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../memory_paths"
|
|
6
6
|
require_relative "provenance"
|
|
7
|
+
require_relative "asset_state"
|
|
7
8
|
require_relative "file_actions"
|
|
8
9
|
require_relative "index_updater"
|
|
9
10
|
require_relative "trash"
|
|
@@ -55,6 +56,8 @@ module Samagotchi
|
|
|
55
56
|
# No file list, just remove provenance (rules, a plugin and scripts
|
|
56
57
|
# live there too)
|
|
57
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)
|
|
58
61
|
FileUtils.rm_rf(provenance.bundle_dir)
|
|
59
62
|
return true
|
|
60
63
|
end
|
|
@@ -144,12 +147,17 @@ module Samagotchi
|
|
|
144
147
|
private
|
|
145
148
|
|
|
146
149
|
# Copies each edited file of the bundle's own folder to the trash: the
|
|
147
|
-
# rm_rf below takes them, --force or not.
|
|
150
|
+
# rm_rf below takes them, --force or not. Rules, the plugin and
|
|
151
|
+
# scripts by their source-less AssetState and ASSET_UNINSTALL's
|
|
152
|
+
# save_first; an older chi's hooks/ by Trash#copy_edits.
|
|
148
153
|
def save_edits(provenance, bundle)
|
|
149
154
|
@trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
{ rule: [provenance.guardrails_dir, bundle.guardrails], plugin: [provenance.plugin_dir, bundle.plugin_shas],
|
|
156
|
+
script: [provenance.scripts_dir, bundle.scripts] }.each do |kind_of_file, (dir, recorded)|
|
|
157
|
+
AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded).each do |asset|
|
|
158
|
+
@trash.copy(asset.path, as: asset.label) if FileActions.for_asset(:uninstall, asset)&.save_first
|
|
159
|
+
end
|
|
160
|
+
end
|
|
153
161
|
end
|
|
154
162
|
|
|
155
163
|
# A kept shared file's index line names the bundle that still has it
|
|
@@ -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
|