samagotchi 0.159.0 → 0.161.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +125 -1
- data/docs/architecture.md +12 -9
- data/docs/cli.md +7 -5
- data/docs/context.md +10 -1
- data/docs/guardrails.md +33 -5
- data/docs/internals/attached-context.md +4 -2
- data/docs/internals/bridge.md +28 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/engine.md +7 -1
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +43 -13
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +53 -8
- data/docs/sessions.md +17 -12
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +103 -44
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast/scope_card.rb +2 -1
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +57 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +6 -0
- data/lib/samagotchi/command_intake.rb +56 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_absorber.rb +6 -3
- data/lib/samagotchi/context_candidate_choice.rb +8 -5
- data/lib/samagotchi/context_candidates.rb +11 -8
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +10 -4
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/context_poller.rb +5 -2
- data/lib/samagotchi/context_sources.rb +6 -4
- data/lib/samagotchi/engine/events.rb +25 -2
- data/lib/samagotchi/engine/guardrails.rb +3 -4
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/events.rb +71 -0
- data/lib/samagotchi/guardrails/approvals.rb +27 -3
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/hook_routes.rb +0 -1
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
- data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +93 -23
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
- data/lib/samagotchi/memory_bundle/status.rb +3 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
- data/lib/samagotchi/memory_orphans.rb +54 -13
- data/lib/samagotchi/memory_paths.rb +29 -6
- data/lib/samagotchi/memory_usage.rb +23 -12
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/plugin/attached_context.rb +3 -1
- data/lib/samagotchi/plugin/sessions.rb +4 -1
- data/lib/samagotchi/project_command.rb +58 -20
- data/lib/samagotchi/project_id.rb +102 -0
- data/lib/samagotchi/project_id_clash.rb +62 -0
- data/lib/samagotchi/project_init.rb +214 -10
- data/lib/samagotchi/project_scope.rb +11 -2
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +374 -76
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +59 -7
- data/lib/samagotchi/session_access/local.rb +53 -22
- data/lib/samagotchi/session_access/remote.rb +13 -5
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +20 -4
- data/lib/samagotchi/session_commands.rb +7 -3
- data/lib/samagotchi/session_continue.rb +162 -24
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +167 -100
- data/lib/samagotchi/session_manager/worker_command.rb +7 -19
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +129 -0
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/context_read.rb +7 -2
- data/lib/samagotchi/tools/delegate.rb +19 -6
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +9 -4
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -5
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +15 -11
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +61 -11
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +62 -10
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +9 -1
|
@@ -6,19 +6,41 @@ 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
|
|
13
19
|
# memory_read miss (Tools::MemoryRead); never in the system prompt.
|
|
14
20
|
module MemoryOrphans
|
|
15
21
|
# @!attribute computed [String] the folder chi reads (this project's key)
|
|
16
|
-
# @!attribute candidates [Array<String>] same-basename folders
|
|
22
|
+
# @!attribute candidates [Array<String>] same-basename folders keyed by
|
|
23
|
+
# another path's hash or by a project id
|
|
17
24
|
# @!attribute computed_exists [Boolean] whether +computed+ is a folder (empty of memories)
|
|
18
25
|
Found = Data.define(:computed, :candidates, :computed_exists)
|
|
19
26
|
|
|
27
|
+
# A project keyed by its path is <name>_<8 hex>; a marked project's folder is
|
|
28
|
+
# its id, <name>-<8 hex>.
|
|
29
|
+
PATH_HASH = /_\h{8}\z/
|
|
30
|
+
|
|
20
31
|
module_function
|
|
21
32
|
|
|
33
|
+
# A candidate folder's key, in words: an earlier path's hash, or a chi project
|
|
34
|
+
# id (a folder marked by `chi project init`, left behind when `git init` makes
|
|
35
|
+
# it a repository). +candidate+ is a folder path.
|
|
36
|
+
def key_note(candidate)
|
|
37
|
+
hash_keyed?(candidate) ? "another path's hash" : "project id #{File.basename(candidate)}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Internal: is this candidate keyed by its path rather than by a project id?
|
|
41
|
+
def hash_keyed?(candidate) = File.basename(candidate).match?(PATH_HASH)
|
|
42
|
+
private_class_method :hash_keyed?
|
|
43
|
+
|
|
22
44
|
# The orphan candidates for +cwd+'s project, or nil: only inside a
|
|
23
45
|
# project, a git repository or a marked folder (outside both the root is
|
|
24
46
|
# cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
|
|
@@ -33,10 +55,14 @@ module Samagotchi
|
|
|
33
55
|
return nil if File.directory?(computed) && Dir.children(computed).any? { |name| name.end_with?(".md") }
|
|
34
56
|
|
|
35
57
|
key = File.basename(computed)
|
|
36
|
-
# The root's basename, never a split of a key on "_" (a basename may hold one)
|
|
37
|
-
|
|
58
|
+
# The root's basename, never a split of a key on "_" (a basename may hold one): another path's key
|
|
59
|
+
# (<basename>_<8 hex>), or a marked project's id folder (<basename>-<8 hex>, ProjectId) a `git init`
|
|
60
|
+
# in the marked folder (git wins: keyed by its path again) left behind.
|
|
61
|
+
pattern = /\A#{Regexp.escape(File.basename(MemoryPaths.project_root(cwd)))}[_-]\h{8}\z/
|
|
38
62
|
names = Dir.children(projects).select { |name| name != key && name.match?(pattern) }
|
|
39
|
-
|
|
63
|
+
# A link is an adopted project's old name (ProjectInit.adopt), never an orphan.
|
|
64
|
+
candidates = names.sort.map { |name| File.join(projects, name) }
|
|
65
|
+
.select { |path| File.directory?(path) && !File.symlink?(path) }
|
|
40
66
|
return nil if candidates.empty?
|
|
41
67
|
|
|
42
68
|
Found.new(computed: computed, candidates: candidates, computed_exists: File.directory?(computed))
|
|
@@ -57,9 +83,18 @@ module Samagotchi
|
|
|
57
83
|
found = find(env: env, cwd: cwd)
|
|
58
84
|
return unless found
|
|
59
85
|
|
|
60
|
-
name
|
|
61
|
-
|
|
62
|
-
|
|
86
|
+
# The project's name is its root folder's basename: splitting the key would
|
|
87
|
+
# print a project id as the name when this project is keyed by one.
|
|
88
|
+
name = File.basename(MemoryPaths.project_root(cwd))
|
|
89
|
+
if found.candidates.size == 1
|
|
90
|
+
candidate = found.candidates.first
|
|
91
|
+
"a folder with the same project name (#{name}) exists under #{key_note(candidate)}; ask the user " \
|
|
92
|
+
"whether its memories belong to this project before using them."
|
|
93
|
+
else
|
|
94
|
+
list = found.candidates.map { |candidate| "#{candidate} (#{key_note(candidate)})" }.join(", ")
|
|
95
|
+
"folders with the same project name (#{name}) exist under other keys (#{list}); ask the user whether their " \
|
|
96
|
+
"memories belong to this project before using them."
|
|
97
|
+
end
|
|
63
98
|
end
|
|
64
99
|
|
|
65
100
|
def text(found)
|
|
@@ -72,13 +107,19 @@ module Samagotchi
|
|
|
72
107
|
else
|
|
73
108
|
"`mv #{candidate} #{computed}`"
|
|
74
109
|
end
|
|
75
|
-
|
|
76
|
-
|
|
110
|
+
why = if hash_keyed?(candidate)
|
|
111
|
+
"has the same project name with #{key_note(candidate)} (memories moved from another machine or " \
|
|
112
|
+
"path?)"
|
|
113
|
+
else
|
|
114
|
+
"has the same project name keyed by a project id (#{File.basename(candidate)}): the folder " \
|
|
115
|
+
"chi project init marked, keyed by its id instead of its path"
|
|
116
|
+
end
|
|
117
|
+
"#{lead}, but #{candidate} #{why}. A name match can't prove it's this project; if it is, #{fix}."
|
|
77
118
|
else
|
|
78
119
|
fix = found.computed_exists ? "move its files into #{computed}" : "`mv <that folder> #{computed}`"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"is, #{fix}."
|
|
120
|
+
list = found.candidates.map { |candidate| "#{candidate} (#{key_note(candidate)})" }.join(", ")
|
|
121
|
+
"#{lead}, but these have the same project name (memories moved from another machine or path?): " \
|
|
122
|
+
"#{list}. A name match can't prove one is this project; if one is, #{fix}."
|
|
82
123
|
end
|
|
83
124
|
end
|
|
84
125
|
end
|
|
@@ -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.
|
|
@@ -20,6 +20,7 @@ module Samagotchi
|
|
|
20
20
|
Usage: chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
|
|
21
21
|
Adds TEXT (or stdin) to each session as a context note: background
|
|
22
22
|
the model sees on its next turn, not a prompt. It starts no turn.
|
|
23
|
+
A session that was continued: the newest link of its chain gets it.
|
|
23
24
|
--source NAME where it came from, shown to the model (default: cli)
|
|
24
25
|
-m TEXT the note; without it, stdin is read
|
|
25
26
|
--all every session a worker runs now, in every project
|
|
@@ -116,10 +117,17 @@ module Samagotchi
|
|
|
116
117
|
# @return [Boolean] whether the note was queued
|
|
117
118
|
def deliver(id, text, source)
|
|
118
119
|
result = @access.note(id, text: text, source: source)
|
|
119
|
-
@stdout.puts("#{id
|
|
120
|
+
@stdout.puts("#{where(id, result)} #{outcome(result)}")
|
|
120
121
|
result.delivered?
|
|
121
122
|
end
|
|
122
123
|
|
|
124
|
+
# The id, or for a session that was continued the link that took it.
|
|
125
|
+
def where(id, result)
|
|
126
|
+
return id[0, 8] unless result.continued?
|
|
127
|
+
|
|
128
|
+
"#{id[0, 8]} → #{result.id[0, 8]} (continued)"
|
|
129
|
+
end
|
|
130
|
+
|
|
123
131
|
def outcome(result)
|
|
124
132
|
case result.status
|
|
125
133
|
when :refused then "refused: #{result.refusal}"
|
|
@@ -5,31 +5,35 @@ require_relative "owner_lock"
|
|
|
5
5
|
require_relative "session_inbox"
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
|
-
# Loaded on first use: this autoload keeps SessionManager and its ~80
|
|
9
|
-
# files unloaded until the delegate/session path needs them.
|
|
10
|
-
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
11
|
-
|
|
12
8
|
# The one rule for putting a context note into a session, shared by
|
|
13
|
-
# `chi note`, the agent's send_note
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# the
|
|
9
|
+
# `chi note`, the agent's send_note, `chi broadcast` and /objectives: a
|
|
10
|
+
# note delivery (SessionInbox.deliver :note). A note to a link that was
|
|
11
|
+
# continued goes to its chain's newest link (SessionInbox::Head); a
|
|
12
|
+
# session a one-turn chi run owns, or a chi scratch session, refuses it;
|
|
13
|
+
# any other gets the note in its notes/ folder, which a live worker adds
|
|
14
|
+
# within seconds and a session with no worker at its next start (a note
|
|
15
|
+
# never starts one). Callers word the outcome (#refusal for a refused one).
|
|
18
16
|
module NoteDelivery
|
|
19
17
|
# Why a one-turn chi run's session takes no note.
|
|
20
18
|
ONE_SHOT_REFUSAL = "it is #{OwnerLock::TUI_BUSY}; send the note once it ends"
|
|
21
19
|
|
|
22
20
|
# The outcome for one session.
|
|
23
|
-
# @!attribute
|
|
24
|
-
#
|
|
25
|
-
#
|
|
21
|
+
# @!attribute id [String] the session that has the note: +asked+, or
|
|
22
|
+
# the newest link of its chain
|
|
23
|
+
# @!attribute status [Symbol] :queued (a worker runs it or is starting),
|
|
24
|
+
# :waits (no worker: it waits for the next start) or :refused (a
|
|
25
|
+
# one-turn chi run owns it, or it is a chi scratch session: +scratch+)
|
|
26
26
|
# @!attribute queued [Integer] notes waiting in its notes/ folder
|
|
27
27
|
# (:waits only; 0 otherwise)
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# @!attribute asked [String] the id the caller named (+id+ by default)
|
|
29
|
+
Result = Data.define(:id, :status, :queued, :scratch, :asked) do
|
|
30
|
+
def initialize(id:, status:, queued:, scratch: false, asked: nil) = super(id:, status:, queued:, scratch:, asked: asked || id)
|
|
30
31
|
|
|
31
32
|
def delivered? = status != :refused
|
|
32
33
|
|
|
34
|
+
# The note went on to a later link of +asked+'s chain.
|
|
35
|
+
def continued? = asked != id
|
|
36
|
+
|
|
33
37
|
# Why a refused note was refused, in its condition's one wording.
|
|
34
38
|
def refusal = scratch ? Session::SCRATCH_REFUSED : ONE_SHOT_REFUSAL
|
|
35
39
|
end
|
|
@@ -38,19 +42,25 @@ module Samagotchi
|
|
|
38
42
|
|
|
39
43
|
# @param id [String] a full session id (resolved by the caller)
|
|
40
44
|
# @param objectives [Hash, nil] an objectives note's payload
|
|
45
|
+
# @param pin [Boolean] keep it in +id+, never its chain's newest link
|
|
46
|
+
# (an /objectives list is the conversation of +id+'s own)
|
|
47
|
+
# @param resolved [SessionInbox::Resolved, nil] the link the caller
|
|
48
|
+
# resolved already (SessionInbox::Head.resolve(:note, id)): no second walk
|
|
41
49
|
# @return [Result]
|
|
42
50
|
# @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
# @raise [Session::NotFound] no such session
|
|
52
|
+
def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil, pin: false,
|
|
53
|
+
resolved: nil)
|
|
54
|
+
body = SessionInbox.checked_text(text)
|
|
55
|
+
delivered = SessionInbox.deliver(:note, id, state_dir: state_dir, pin: pin, resolved: resolved) do |at|
|
|
56
|
+
SessionInbox.write_note(at, text: body, source: source, from_session: from_session, from_cwd: from_cwd,
|
|
57
|
+
objectives: objectives, state_dir: state_dir)
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
raise Session::NotFound, id if delivered.wake == :gone
|
|
52
61
|
|
|
53
|
-
Result.new(id: id, status:
|
|
62
|
+
Result.new(id: delivered.id, asked: delivered.asked, status: delivered.status, queued: delivered.queued || 0,
|
|
63
|
+
scratch: delivered.refused? && delivered.wake == :scratch)
|
|
54
64
|
end
|
|
55
65
|
end
|
|
56
66
|
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "archive_store"
|
|
7
|
+
require_relative "context_move"
|
|
8
|
+
require_relative "log"
|
|
9
|
+
require_relative "objectives"
|
|
10
|
+
require_relative "session"
|
|
11
|
+
require_relative "session_chain"
|
|
12
|
+
require_relative "session_inbox"
|
|
13
|
+
|
|
14
|
+
module Samagotchi
|
|
15
|
+
# A session chain's notes/ across a continue. The continue moves the
|
|
16
|
+
# previous link's waiting notes to the new link (.move, the last step
|
|
17
|
+
# of its before_spawn); a continue that fails gives them back (.give_back);
|
|
18
|
+
# the new link's worker adopts a note written to the previous link after
|
|
19
|
+
# the move (.adoption: a writer that resolved the id just before the new
|
|
20
|
+
# link was saved, or an older chi that knows no chain head). A note keeps
|
|
21
|
+
# its file name everywhere: its id, and its place in arrival order.
|
|
22
|
+
#
|
|
23
|
+
# Objectives lists stay where they are: an /objectives note is its own
|
|
24
|
+
# session's (pinned), and the continue's Objectives.carry already took the
|
|
25
|
+
# previous link's newest one into the new link's list.
|
|
26
|
+
module NoteMove
|
|
27
|
+
# The notes a continue writes into its new link itself (the chain note,
|
|
28
|
+
# ChildMove's, the attached context's, the objectives' carry): a failed
|
|
29
|
+
# continue deletes them with the link.
|
|
30
|
+
CONTINUE_SOURCES = [SessionChain::NOTE_SOURCE, ContextMove::NOTE_SOURCE, Objectives::SOURCE].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Moves +from+'s notes into +to+'s notes/, but not its objectives lists
|
|
35
|
+
# nor the notes a continue into +from+ wrote there (CONTINUE_SOURCES: a
|
|
36
|
+
# link continued before it ever started holds its own chain note, which
|
|
37
|
+
# the new link's own replaces). The rename is the claim: one a worker or
|
|
38
|
+
# another move took meanwhile is skipped. A claimed one
|
|
39
|
+
# (*.json.processing: an old worker took it and never absorbed it, or
|
|
40
|
+
# absorbed it and died before the delete) moves too, un-claimed, logged
|
|
41
|
+
# (notes_moved_claimed): the link is archived, so left there it would be
|
|
42
|
+
# lost, and a note seen twice beats a lost one.
|
|
43
|
+
# @return [Array<String>] the file names moved (un-claimed), oldest first
|
|
44
|
+
def move(from:, to:, state_dir:)
|
|
45
|
+
files = Dir.glob(File.join(notes_dir(from, state_dir), "*.{json,json.processing}"))
|
|
46
|
+
.sort_by { |file| unclaimed_name(file) }
|
|
47
|
+
.select { |file| (data = read(file)) && !objectives?(data) && !CONTINUE_SOURCES.include?(data["source"]) }
|
|
48
|
+
moved = rename(files, notes_dir(to, state_dir))
|
|
49
|
+
claimed = files.count { |file| file.end_with?(".processing") && moved.include?(unclaimed_name(file)) }
|
|
50
|
+
Log.info(:worker, "notes_moved_claimed", from: from[0, 8], to: to[0, 8], count: claimed) if claimed.positive?
|
|
51
|
+
Log.info(:worker, "notes_moved", from: from[0, 8], to: to[0, 8], count: moved.size) unless moved.empty?
|
|
52
|
+
moved
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A failed continue's undo, before its link +from+ is deleted: back to
|
|
56
|
+
# +to+ go the notes it moved (+moved+) and any other sent to the link
|
|
57
|
+
# meanwhile, never one the continue wrote itself (CONTINUE_SOURCES).
|
|
58
|
+
# @return [Array<String>] the file names given back
|
|
59
|
+
def give_back(from:, to:, moved:, state_dir:)
|
|
60
|
+
files = unclaimed(notes_dir(from, state_dir)).select do |file|
|
|
61
|
+
next true if moved.include?(File.basename(file))
|
|
62
|
+
|
|
63
|
+
data = read(file)
|
|
64
|
+
data && !objectives?(data) && !CONTINUE_SOURCES.include?(data["source"])
|
|
65
|
+
end
|
|
66
|
+
rename(files, notes_dir(to, state_dir)).tap do |back|
|
|
67
|
+
Log.info(:worker, "notes_given_back", from: from[0, 8], to: to[0, 8], count: back.size) unless back.empty?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# How far back along the chain a worker adopts (Adoption): the links
|
|
72
|
+
# continued into it, newest first.
|
|
73
|
+
ADOPT_MAX_LINKS = 8
|
|
74
|
+
|
|
75
|
+
# What a worker for +session+ adopts from the links before it, nil when
|
|
76
|
+
# there is nothing to adopt from: a first link, a predecessor not
|
|
77
|
+
# archived (checked first: no listing then), or one whose next link
|
|
78
|
+
# (SessionChain.next_of) is another. Else one listing walks back: each
|
|
79
|
+
# archived link whose next link is the one after it, from the newest, up
|
|
80
|
+
# to ADOPT_MAX_LINKS, stopping at one not archived or gone; each is
|
|
81
|
+
# adopted from by the creation of the link that continued it.
|
|
82
|
+
# @param session [Session]
|
|
83
|
+
# @return [Adoption, nil]
|
|
84
|
+
def adoption(session, state_dir:)
|
|
85
|
+
from = session.continues
|
|
86
|
+
return nil unless Session.valid_id?(from) && archived?(from, state_dir)
|
|
87
|
+
|
|
88
|
+
sessions = Session.list(state_dir: state_dir, sort: "created_at", order: "asc", include_archived: true)
|
|
89
|
+
byid = sessions.to_h { |s| [s.id, s] }
|
|
90
|
+
nexts = sessions.each_with_object({}) { |s, map| map[s.continues] ||= s.id if s.continues }
|
|
91
|
+
sources = []
|
|
92
|
+
child = session
|
|
93
|
+
while Session.valid_id?(from) && sources.size < ADOPT_MAX_LINKS && sources.none? { |src| src.from == from }
|
|
94
|
+
since = time_of(child.created_at)
|
|
95
|
+
break unless since && nexts[from] == child.id && archived?(from, state_dir)
|
|
96
|
+
|
|
97
|
+
sources << Source.new(from: from, since: since.to_i)
|
|
98
|
+
child = byid[from] or break
|
|
99
|
+
from = child.continues
|
|
100
|
+
end
|
|
101
|
+
sources.empty? ? nil : Adoption.new(to: session.id, sources: sources, state_dir: state_dir)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A link adopted from (#adoption) and from when (whole seconds: the
|
|
105
|
+
# creation of the link that continued it).
|
|
106
|
+
Source = Data.define(:from, :since)
|
|
107
|
+
|
|
108
|
+
# A worker's adoption of the notes the links before it get after their
|
|
109
|
+
# continue: #call on each pass takes them while each link is archived
|
|
110
|
+
# (back in the lists, its notes are its own, and the walk stops there).
|
|
111
|
+
# Only notes from the second the next link was made on (a note's stamp
|
|
112
|
+
# has whole seconds): older ones are from before the continue (a
|
|
113
|
+
# continue made before notes moved left them), and would come days late,
|
|
114
|
+
# out of order with the conversation. They, objectives lists and claimed
|
|
115
|
+
# ones stay, each logged once.
|
|
116
|
+
class Adoption
|
|
117
|
+
def initialize(to:, sources:, state_dir:)
|
|
118
|
+
@to = to
|
|
119
|
+
@sources = sources
|
|
120
|
+
@state_dir = state_dir
|
|
121
|
+
@to_dir = NoteMove.notes_dir(to, state_dir)
|
|
122
|
+
@left = Set.new
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Array<String>] the file names adopted
|
|
126
|
+
def call
|
|
127
|
+
adopted = []
|
|
128
|
+
@sources.each do |source|
|
|
129
|
+
break unless NoteMove.archived?(source.from, @state_dir)
|
|
130
|
+
|
|
131
|
+
adopted.concat(adopt(source))
|
|
132
|
+
end
|
|
133
|
+
adopted
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def adopt(source)
|
|
139
|
+
files = NoteMove.unclaimed(NoteMove.notes_dir(source.from, @state_dir)).reject { |file| @left.include?(file) }
|
|
140
|
+
return [] if files.empty?
|
|
141
|
+
|
|
142
|
+
take, leave = files.partition { |file| adoptable?(NoteMove.read(file), source.since) }
|
|
143
|
+
log_left(source, leave)
|
|
144
|
+
NoteMove.rename(take, @to_dir).tap do |adopted|
|
|
145
|
+
Log.info(:worker, "notes_adopted", from: source.from[0, 8], sid: @to[0, 8], count: adopted.size) unless adopted.empty?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def adoptable?(data, since)
|
|
150
|
+
return false if data.nil? || NoteMove.objectives?(data)
|
|
151
|
+
|
|
152
|
+
(time = NoteMove.time_of(data["created_at"])) && time.to_i >= since
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def log_left(source, files)
|
|
156
|
+
return if files.empty?
|
|
157
|
+
|
|
158
|
+
@left.merge(files)
|
|
159
|
+
Log.info(:worker, "notes_left_old", from: source.from[0, 8], sid: @to[0, 8], count: files.size)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def archived?(id, state_dir) = ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
|
|
164
|
+
|
|
165
|
+
# @return [Time, nil]
|
|
166
|
+
def time_of(value)
|
|
167
|
+
Time.iso8601(value.to_s)
|
|
168
|
+
rescue ArgumentError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def notes_dir(id, state_dir) = File.join(Session.session_dir(id, state_dir: state_dir), SessionInbox::NOTES_DIR)
|
|
173
|
+
|
|
174
|
+
# The unclaimed note files, oldest first.
|
|
175
|
+
def unclaimed(dir) = Dir.glob(File.join(dir, "*.json")).sort_by { |file| File.basename(file) }
|
|
176
|
+
|
|
177
|
+
# @return [Hash, nil] the note file's JSON, nil when it can't be read
|
|
178
|
+
def read(file)
|
|
179
|
+
data = JSON.parse(File.read(file))
|
|
180
|
+
data.is_a?(Hash) ? data : nil
|
|
181
|
+
rescue JSON::ParserError, SystemCallError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def objectives?(data) = data.is_a?(Hash) && (data["objectives"].is_a?(Hash) || data["source"] == Objectives::SOURCE)
|
|
186
|
+
|
|
187
|
+
# A note file's name without a claim's ".processing".
|
|
188
|
+
def unclaimed_name(file) = File.basename(file).delete_suffix(".processing")
|
|
189
|
+
|
|
190
|
+
# @return [Array<String>] the names renamed into +dir+, un-claimed (one
|
|
191
|
+
# taken meanwhile is skipped)
|
|
192
|
+
def rename(files, dir)
|
|
193
|
+
return [] if files.empty?
|
|
194
|
+
|
|
195
|
+
FileUtils.mkdir_p(dir)
|
|
196
|
+
files.filter_map do |file|
|
|
197
|
+
name = unclaimed_name(file)
|
|
198
|
+
File.rename(file, File.join(dir, name))
|
|
199
|
+
name
|
|
200
|
+
rescue Errno::ENOENT
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -97,8 +97,10 @@ module Samagotchi
|
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
def deliver(list, session)
|
|
100
|
+
# Pinned: the list is this session's own, never its chain's newest link's.
|
|
100
101
|
result = NoteDelivery.deliver(session.id, text: list.text, source: Objectives::SOURCE,
|
|
101
|
-
state_dir: @engine.session_state_dir, objectives: list.to_payload
|
|
102
|
+
state_dir: @engine.session_state_dir, objectives: list.to_payload,
|
|
103
|
+
pin: true)
|
|
102
104
|
return ["refused: #{result.refusal}", :error] unless result.delivered?
|
|
103
105
|
|
|
104
106
|
@engine.announce_objectives(session)
|
|
@@ -23,6 +23,8 @@ module Samagotchi
|
|
|
23
23
|
# web's continueRefusal reads "<short> (why)" out of a refusal.
|
|
24
24
|
TUI_BUSY = "in use by a one-turn chi --non-interactive run or an older chi's terminal"
|
|
25
25
|
DEFAULT_WAIT = 2.0
|
|
26
|
+
# The kind a deleter holds the lock as while a session's files go.
|
|
27
|
+
DELETING = "deleting"
|
|
26
28
|
RETRY_INTERVAL = 0.05
|
|
27
29
|
OWNER_READ_ATTEMPTS = 10
|
|
28
30
|
# Seconds .await_release waits past a leaving worker's own budget: its
|
|
@@ -33,7 +35,7 @@ module Samagotchi
|
|
|
33
35
|
|
|
34
36
|
# Who holds the lock, as .owner reads it from the lock file. Its fields
|
|
35
37
|
# may be nil for a moment right after acquisition.
|
|
36
|
-
# kind: "worker" or "
|
|
38
|
+
# kind: "worker", "tui" or "deleting" (Owner#deleting?); pid as written (an Integer). leaving_at (an
|
|
37
39
|
# ISO 8601 time) and leave_wait (seconds): set once a worker is leaving
|
|
38
40
|
# (#mark_leaving), its Bridge closed, the lock held for its last work
|
|
39
41
|
# (the recap on leave) for up to leave_wait.
|
|
@@ -45,6 +47,10 @@ module Samagotchi
|
|
|
45
47
|
|
|
46
48
|
def worker? = kind == "worker"
|
|
47
49
|
|
|
50
|
+
# A deleter removing the session's files (SessionManager's
|
|
51
|
+
# remove_session_files): no worker, nothing to signal.
|
|
52
|
+
def deleting? = kind == DELETING
|
|
53
|
+
|
|
48
54
|
# A worker that closed its Bridge and releases the lock once its
|
|
49
55
|
# last work is done.
|
|
50
56
|
def leaving? = worker? && !leaving_at.nil?
|
|
@@ -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
|