samagotchi 0.148.0 → 0.150.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 +62 -1
- data/README.md +4 -2
- data/docs/architecture.md +3 -3
- data/docs/bundles.md +38 -1
- data/docs/cli.md +76 -24
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +2 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +42 -14
- data/docs/internals/objectives.md +269 -0
- data/docs/internals/web-frontend.md +4 -1
- data/docs/internals/web-push.md +2 -2
- data/docs/internals/working-set.md +5 -2
- data/docs/memory.md +47 -9
- data/docs/plugins.md +10 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +45 -2
- data/docs/system-one.md +17 -1
- data/docs/testing.md +5 -0
- data/lib/samagotchi/atomic_file.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +5 -2
- data/lib/samagotchi/bridge/card_store.rb +52 -7
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +91 -68
- data/lib/samagotchi/bundler_env.rb +24 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
- data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
- data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
- data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/chi_home.rb +4 -2
- data/lib/samagotchi/chi_shim.rb +2 -1
- data/lib/samagotchi/child_env.rb +10 -3
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/context_note.rb +17 -5
- data/lib/samagotchi/engine.rb +113 -6
- data/lib/samagotchi/engine_peers.rb +11 -0
- data/lib/samagotchi/git_env.rb +43 -0
- data/lib/samagotchi/guardrails/context.rb +2 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +4 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
- data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
- data/lib/samagotchi/memory_bundle/installer.rb +161 -52
- data/lib/samagotchi/memory_bundle/listing.rb +4 -3
- data/lib/samagotchi/memory_bundle/merger.rb +209 -8
- data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
- data/lib/samagotchi/memory_bundle/profile.rb +7 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
- data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
- data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
- data/lib/samagotchi/memory_bundle/source.rb +3 -3
- data/lib/samagotchi/memory_bundle/status.rb +21 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
- data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
- data/lib/samagotchi/memory_bundle/trash.rb +58 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +6 -2
- data/lib/samagotchi/objectives.rb +395 -0
- data/lib/samagotchi/objectives_audit.rb +115 -0
- data/lib/samagotchi/objectives_check.rb +365 -0
- data/lib/samagotchi/objectives_command.rb +192 -0
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +21 -1
- data/lib/samagotchi/project_env.rb +3 -2
- data/lib/samagotchi/self_report.rb +2 -0
- data/lib/samagotchi/session_commands.rb +15 -0
- data/lib/samagotchi/session_continue.rb +3 -0
- data/lib/samagotchi/session_inbox.rb +14 -6
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/steer.rb +24 -4
- data/lib/samagotchi/testing/boundary.rb +2 -0
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/testing/minitest.rb +3 -0
- data/lib/samagotchi/testing/rspec.rb +3 -0
- data/lib/samagotchi/testing/sandbox.rb +3 -1
- data/lib/samagotchi/tool_activity.rb +17 -0
- data/lib/samagotchi/tool_declarations.rb +34 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/delegate.rb +2 -1
- data/lib/samagotchi/tools/objectives_tool.rb +75 -0
- data/lib/samagotchi/tools/peers.rb +3 -0
- data/lib/samagotchi/tools/task_runtime.rb +13 -10
- data/lib/samagotchi/turn_flow.rb +3 -1
- data/lib/samagotchi/update_command.rb +76 -28
- data/lib/samagotchi/update_hint.rb +4 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/auto_update.rb +8 -8
- data/lib/samagotchi/web/auto_update_record.rb +6 -4
- data/lib/samagotchi/web/message_display.rb +9 -0
- data/lib/samagotchi/web/participant_view.rb +2 -2
- data/lib/samagotchi/web/public/app.js +15 -3
- data/lib/samagotchi/web/public/block_open.js +13 -0
- data/lib/samagotchi/web/public/bubbles.js +4 -2
- data/lib/samagotchi/web/public/cards_place.js +30 -9
- data/lib/samagotchi/web/public/commands.js +4 -1
- data/lib/samagotchi/web/public/details_motion.js +2 -1
- data/lib/samagotchi/web/public/index.html +20 -14
- data/lib/samagotchi/web/public/objectives.css +80 -0
- data/lib/samagotchi/web/public/objectives_entry.js +22 -0
- data/lib/samagotchi/web/public/objectives_model.js +100 -0
- data/lib/samagotchi/web/public/objectives_moves.js +145 -0
- data/lib/samagotchi/web/public/objectives_strip.js +242 -0
- data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
- data/lib/samagotchi/web/public/stage_model.js +5 -6
- data/lib/samagotchi/web/public/stage_view.js +8 -7
- data/lib/samagotchi/web/public/stream_handlers.js +6 -1
- data/lib/samagotchi/web/public/turn_block.js +34 -19
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/public/turn_model.js +1 -1
- data/lib/samagotchi/web/public/update_sheet.js +8 -3
- data/lib/samagotchi/web/public/working_set_view.js +10 -7
- data/lib/samagotchi/web/push/notifier.rb +7 -9
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_show.rb +12 -1
- data/lib/samagotchi/web/update_ops.rb +2 -1
- data/lib/samagotchi/worker.rb +3 -0
- data/lib/samagotchi/worker_inbound.rb +16 -8
- metadata +22 -1
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require_relative "../memory_paths"
|
|
5
|
+
require_relative "provenance"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module MemoryBundle
|
|
8
9
|
# Where a bundle operation puts the memory files it takes away
|
|
9
10
|
# (uninstall, an upgrade's dropped file): moved, never deleted, into
|
|
10
11
|
# <system memories>/.bundles/.trash/<bundle>-<YYYYmmdd-HHMMSS>/, one dir
|
|
11
|
-
# per operation (-2, -3… when one already has that name). A
|
|
12
|
+
# per operation (-2, -3… when one already has that name). A file about to
|
|
13
|
+
# be overwritten is copied there instead (#copy). A dot dir, so
|
|
12
14
|
# Provenance.each_installed never reads it as a bundle.
|
|
13
15
|
#
|
|
14
16
|
# `chi bundle trash` lists and empties the trash.
|
|
@@ -55,14 +57,25 @@ module Samagotchi
|
|
|
55
57
|
entries
|
|
56
58
|
end
|
|
57
59
|
|
|
58
|
-
# The
|
|
60
|
+
# The one refusal for a file system error while files are being saved and
|
|
61
|
+
# replaced. Every save comes before its removal, so an edit is where it
|
|
62
|
+
# was or in the trash; a retry finds what this run already placed.
|
|
63
|
+
def self.failure_message(error)
|
|
64
|
+
"#{error.message}: nothing was lost (your edits are in place or in the trash); fix that and run the command again"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The dir, once a file was moved or copied (nil before).
|
|
59
68
|
attr_reader :dir
|
|
60
69
|
|
|
70
|
+
# What #copy saved, each by its name in the dir.
|
|
71
|
+
attr_reader :copied
|
|
72
|
+
|
|
61
73
|
def initialize(name, now: Time.now, bundles_dir: MemoryPaths.bundles_dir)
|
|
62
74
|
@name = name.to_s
|
|
63
75
|
@now = now
|
|
64
76
|
@bundles_dir = bundles_dir
|
|
65
77
|
@dir = nil
|
|
78
|
+
@copied = []
|
|
66
79
|
end
|
|
67
80
|
|
|
68
81
|
# Moves +path+ into the trash dir (created on the first call).
|
|
@@ -73,6 +86,49 @@ module Samagotchi
|
|
|
73
86
|
dest
|
|
74
87
|
end
|
|
75
88
|
|
|
89
|
+
# Copies +path+ (through a link, its mode kept) into the trash dir,
|
|
90
|
+
# the original staying: for a file about to be overwritten.
|
|
91
|
+
# @param as [String] its name there; "hooks/x" keeps a subfolder, so
|
|
92
|
+
# hooks/x and scripts/x don't collide
|
|
93
|
+
# @return [String] the copy's path
|
|
94
|
+
def copy(path, as: File.basename(path))
|
|
95
|
+
dest = File.join(ensure_dir, as)
|
|
96
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
97
|
+
FileUtils.cp(path, dest, preserve: true)
|
|
98
|
+
@copied << as
|
|
99
|
+
dest
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Copies the file at +path+ as +as+ before it is replaced or removed, when
|
|
103
|
+
# that would lose something: its bytes are neither what the record says
|
|
104
|
+
# was installed (no sha recorded: unknown, so it counts as edited) nor
|
|
105
|
+
# +incoming+'s. A link (a dev install's) points at its source, which
|
|
106
|
+
# nothing here touches.
|
|
107
|
+
def copy_edit(path, as:, recorded: nil, incoming: nil)
|
|
108
|
+
return if File.symlink?(path) || !File.file?(path)
|
|
109
|
+
return if incoming && File.file?(incoming) && FileUtils.identical?(incoming, path)
|
|
110
|
+
return if !Provenance.recorded_sha(recorded).empty? && Provenance.sha_matches?(path, recorded)
|
|
111
|
+
|
|
112
|
+
copy(path, as: as)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# copy_edit for every file under +dir+, which is about to go, at any depth
|
|
116
|
+
# (kept as <label>/<its path in dir>). A linked dir isn't followed.
|
|
117
|
+
# @param recorded [Hash, nil] path in dir => its recorded sha256
|
|
118
|
+
# @param incoming [Hash] path in dir => the source replacing it
|
|
119
|
+
def copy_edits(dir, label, recorded = nil, incoming = {})
|
|
120
|
+
return if File.symlink?(dir) || !File.directory?(dir)
|
|
121
|
+
|
|
122
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.each do |file|
|
|
123
|
+
copy_edit(File.join(dir, file), as: "#{label}/#{file}", recorded: recorded&.[](file), incoming: incoming[file])
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# What #copy saved, said once (nil: nothing).
|
|
128
|
+
def copy_line
|
|
129
|
+
"Copied to the trash first: #{@copied.join(", ")} (#{@dir})" unless @copied.empty?
|
|
130
|
+
end
|
|
131
|
+
|
|
76
132
|
private
|
|
77
133
|
|
|
78
134
|
def ensure_dir
|
|
@@ -18,9 +18,10 @@ module Samagotchi
|
|
|
18
18
|
def self.not_installed(name) = new("Bundle '#{name}' is not installed")
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
# removed_files: the bundle's own hooks/ and plugin/ files (deleted
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# removed_files: the bundle's own hooks/ and plugin/ files (deleted, an
|
|
22
|
+
# edited one copied to trash_dir first: trash_copy_line);
|
|
23
|
+
# trashed_files: its memory files, moved to trash_dir (Trash), which is
|
|
24
|
+
# nil when nothing went there.
|
|
24
25
|
attr_reader :warnings, :removed_files, :trashed_files
|
|
25
26
|
|
|
26
27
|
def initialize(name:, scope: nil, force: false)
|
|
@@ -35,6 +36,10 @@ module Samagotchi
|
|
|
35
36
|
|
|
36
37
|
def trash_dir = @trash.dir
|
|
37
38
|
|
|
39
|
+
# What it copied to the trash from the bundle's own folder, said once
|
|
40
|
+
# (nil: nothing).
|
|
41
|
+
def trash_copy_line = @trash.copy_line
|
|
42
|
+
|
|
38
43
|
def run
|
|
39
44
|
provenance = Provenance.new(name: @name)
|
|
40
45
|
bundle = provenance.record
|
|
@@ -47,7 +52,9 @@ module Samagotchi
|
|
|
47
52
|
files = bundle.files
|
|
48
53
|
hooks = bundle.hooks
|
|
49
54
|
if files.empty? && hooks.empty?
|
|
50
|
-
# No file list, just remove provenance
|
|
55
|
+
# No file list, just remove provenance (rules, a plugin and scripts
|
|
56
|
+
# live there too)
|
|
57
|
+
save_edits(provenance, bundle)
|
|
51
58
|
FileUtils.rm_rf(provenance.bundle_dir)
|
|
52
59
|
return true
|
|
53
60
|
end
|
|
@@ -67,11 +74,13 @@ module Samagotchi
|
|
|
67
74
|
|
|
68
75
|
if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
|
|
69
76
|
blocked << file_key_str
|
|
70
|
-
@warnings << "Skipped #{file_key_str}: local edits detected (
|
|
77
|
+
@warnings << "Skipped #{file_key_str}: local edits detected (#{FileStanding::FORCE_MOVES})"
|
|
71
78
|
end
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
raise UninstallError, "Uninstall blocked: #{blocked.join(", ")} has local edits (
|
|
81
|
+
raise UninstallError, "Uninstall blocked: #{blocked.join(", ")} has local edits (#{FileStanding::FORCE_MOVES})" if blocked.any?
|
|
82
|
+
|
|
83
|
+
save_edits(provenance, bundle)
|
|
75
84
|
|
|
76
85
|
files.each do |file_key, _meta|
|
|
77
86
|
file_key_str = file_key.to_s
|
|
@@ -123,10 +132,21 @@ module Samagotchi
|
|
|
123
132
|
@removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
|
|
124
133
|
FileUtils.rm_rf(provenance.bundle_dir)
|
|
125
134
|
true
|
|
135
|
+
rescue SystemCallError => e
|
|
136
|
+
raise UninstallError, Trash.failure_message(e)
|
|
126
137
|
end
|
|
127
138
|
|
|
128
139
|
private
|
|
129
140
|
|
|
141
|
+
# Copies each edited file of the bundle's own folder to the trash: the
|
|
142
|
+
# rm_rf below takes them, --force or not.
|
|
143
|
+
def save_edits(provenance, bundle)
|
|
144
|
+
@trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
|
|
145
|
+
@trash.copy_edits(provenance.guardrails_dir, "guardrails", bundle.guardrails)
|
|
146
|
+
@trash.copy_edits(provenance.plugin_dir, "plugin", bundle.plugin_shas)
|
|
147
|
+
@trash.copy_edits(provenance.scripts_dir, "scripts", bundle.scripts)
|
|
148
|
+
end
|
|
149
|
+
|
|
130
150
|
# A kept shared file's index line names the bundle that still has it
|
|
131
151
|
# (a model overlay has none: the caller skips it).
|
|
132
152
|
def retag(scope, path, file_key, bundle)
|
|
@@ -123,7 +123,7 @@ module Samagotchi
|
|
|
123
123
|
|
|
124
124
|
def outcome(result)
|
|
125
125
|
case result.status
|
|
126
|
-
when :refused then "refused:
|
|
126
|
+
when :refused then "refused: #{NoteDelivery::REPL_REFUSAL}"
|
|
127
127
|
when :queued then "queued: its worker adds it within a few seconds"
|
|
128
128
|
else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
|
|
129
129
|
end
|
|
@@ -14,6 +14,9 @@ module Samagotchi
|
|
|
14
14
|
# note in its notes/ folder, which a live worker adds within seconds and
|
|
15
15
|
# a session with no worker at its next start. Callers word the outcome.
|
|
16
16
|
module NoteDelivery
|
|
17
|
+
# Why a chi REPL's session takes no note.
|
|
18
|
+
REPL_REFUSAL = "it is open in a chi REPL; notes need attached mode"
|
|
19
|
+
|
|
17
20
|
# The outcome for one session.
|
|
18
21
|
# @!attribute status [Symbol] :queued (a worker runs it), :waits (no
|
|
19
22
|
# worker: it waits for the next start) or :refused (a chi REPL owns it)
|
|
@@ -26,14 +29,15 @@ module Samagotchi
|
|
|
26
29
|
module_function
|
|
27
30
|
|
|
28
31
|
# @param id [String] a full session id (resolved by the caller)
|
|
32
|
+
# @param objectives [Hash, nil] an objectives note's payload
|
|
29
33
|
# @return [Result]
|
|
30
34
|
# @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
|
|
31
|
-
def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil)
|
|
35
|
+
def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil)
|
|
32
36
|
owner = SessionManager.session_owner(id, state_dir: state_dir)
|
|
33
37
|
return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
|
|
34
38
|
|
|
35
39
|
path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
|
|
36
|
-
state_dir: state_dir)
|
|
40
|
+
objectives: objectives, state_dir: state_dir)
|
|
37
41
|
return Result.new(id: id, status: :queued, queued: 0) if owner
|
|
38
42
|
|
|
39
43
|
Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "atomic_file"
|
|
8
|
+
require_relative "context_note"
|
|
9
|
+
require_relative "llm_context_stale"
|
|
10
|
+
require_relative "log"
|
|
11
|
+
require_relative "message_time"
|
|
12
|
+
require_relative "objectives_check"
|
|
13
|
+
require_relative "session"
|
|
14
|
+
require_relative "session_chain"
|
|
15
|
+
require_relative "session_inbox"
|
|
16
|
+
require_relative "tools/objectives_tool"
|
|
17
|
+
|
|
18
|
+
module Samagotchi
|
|
19
|
+
# A session's objectives: what the user wants it to achieve, a list they
|
|
20
|
+
# type (/objectives, ObjectivesCommand). The list lives in the conversation
|
|
21
|
+
# as a note entry (ContextNote::OBJECTIVES_HEADER, its payload under the
|
|
22
|
+
# message's :objectives), each edit a whole new list that replaces the one
|
|
23
|
+
# before it. Pure but for the file reads named below, built from a
|
|
24
|
+
# conversation as WorkingSet is. The model's moves are its calls of the
|
|
25
|
+
# objectives tool (Tools::ObjectivesTool), read back from the same
|
|
26
|
+
# conversation: not entries. See docs/internals/objectives.md.
|
|
27
|
+
module Objectives
|
|
28
|
+
# Who a list's note is from (ContextNote's label).
|
|
29
|
+
SOURCE = "objectives"
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
DONE = "done"
|
|
32
|
+
DROPPED = "dropped"
|
|
33
|
+
STATUSES = [ACTIVE, DONE, DROPPED].freeze
|
|
34
|
+
ID = /\AO(\d+)\z/
|
|
35
|
+
# What a list with nothing left to send says, so that it still replaces
|
|
36
|
+
# the one before it.
|
|
37
|
+
NONE_LEFT = "(no objectives now)"
|
|
38
|
+
# Cards a turn may leave: a model's proposals are for reading.
|
|
39
|
+
PROPOSALS_PER_TURN = 3
|
|
40
|
+
# Notes that say nothing new from outside: the list's own, and a continue's
|
|
41
|
+
# carry note, which holds the agent's last answer (#outside_note?).
|
|
42
|
+
NOT_OUTSIDE = [SOURCE, SessionChain::NOTE_SOURCE].freeze
|
|
43
|
+
# Beside the session's notes/: what #with_lock takes.
|
|
44
|
+
LOCK_FILE = "objectives.lock"
|
|
45
|
+
# Proposal cards answered, {card id => APPLIED | DISMISSED}, so that a repeat
|
|
46
|
+
# of a card's line can't apply again. Under #with_lock.
|
|
47
|
+
CARDS_FILE = "objectives_cards.json"
|
|
48
|
+
APPLIED = "applied"
|
|
49
|
+
DISMISSED = "dismissed"
|
|
50
|
+
# A text's own done-when: the last group, an earlier one stays text.
|
|
51
|
+
DONE_WHEN = /\A(.*)\(done when:\s*(.*?)\s*\)\z/i
|
|
52
|
+
|
|
53
|
+
# One objective: +id+ "O1", never reused; +status+ ACTIVE, DONE or
|
|
54
|
+
# DROPPED; +done_when+ an optional hint, never enforced.
|
|
55
|
+
Item = Data.define(:id, :text, :status, :done_when) do
|
|
56
|
+
def initialize(id:, text:, status: ACTIVE, done_when: nil)
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The line the frame and /objectives show.
|
|
61
|
+
def line
|
|
62
|
+
mark = status == ACTIVE ? "" : "[#{status}] "
|
|
63
|
+
"#{id} #{mark}#{text}#{" (done when: #{done_when})" if done_when}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_payload = { "id" => id, "text" => text, "status" => status, "done_when" => done_when }.compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# What the model last said moved an objective: information only, read
|
|
70
|
+
# from its tool call, and shown by /objectives.
|
|
71
|
+
# +ref+ names the call itself, words apart: its run's id and its model
|
|
72
|
+
# entry's time, so a later move in the same words is another move, and so is
|
|
73
|
+
# one after a rollback hands the same id out again.
|
|
74
|
+
Move = Data.define(:id, :note, :evidence, :ref) do
|
|
75
|
+
def initialize(id:, note:, evidence:, ref: nil) = super
|
|
76
|
+
|
|
77
|
+
def line = evidence.empty? ? note : "#{note} (evidence: #{evidence})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# What the user did to one objective between two lists, for the entry's
|
|
81
|
+
# line: +kind+ added, done, dropped, reopened or reworded; +was+ a
|
|
82
|
+
# reworded one's old text. Display only, computed from the two lists.
|
|
83
|
+
Change = Data.define(:id, :kind, :text, :was) do
|
|
84
|
+
def to_h = { id: id, kind: kind, text: text, was: was }.compact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A change the model proposes (the objectives tool), shown to the user as a
|
|
88
|
+
# card whose action is #line: the same edit they could type. +change+ add,
|
|
89
|
+
# reword, done or drop; +note+ why.
|
|
90
|
+
Proposal = Data.define(:change, :id, :text, :note, :was) do
|
|
91
|
+
# +was+: what a reword was proposed against (Objectives.fingerprint).
|
|
92
|
+
def initialize(change:, id:, text:, note:, was: nil)
|
|
93
|
+
super
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A proposal against +list+: a reword keeps the item's done-when unless its
|
|
97
|
+
# text brings one, and carries what it was proposed against.
|
|
98
|
+
def self.against(list, change:, id:, text:, note:)
|
|
99
|
+
item = change == "reword" ? list.find(id) : nil
|
|
100
|
+
return new(change: change, id: id, text: text, note: note) unless item
|
|
101
|
+
|
|
102
|
+
kept = item.done_when && !text.empty? && !text.match?(DONE_WHEN) ? "#{text} (done when: #{item.done_when})" : text
|
|
103
|
+
new(change: change, id: id, text: kept, note: note, was: Objectives.fingerprint(item))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# What's wrong with it against +list+, nil when it can apply.
|
|
107
|
+
def refusal(list)
|
|
108
|
+
return "change must be add, reword, done or drop" unless %w[add reword done drop].include?(change)
|
|
109
|
+
return "#{change} needs a text" if %w[add reword].include?(change) && text.empty?
|
|
110
|
+
return nil if change == "add"
|
|
111
|
+
|
|
112
|
+
list.find(id) ? nil : "no objective #{id} (this session's: #{list.items.map(&:id).join(", ")})"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The /objectives line that applies it. An add's text can't split in two.
|
|
116
|
+
def line
|
|
117
|
+
case change
|
|
118
|
+
when "add" then "/objectives add #{text.gsub(/(?<!\S)\|(?!\S)/, "/")}"
|
|
119
|
+
when "reword" then "/objectives edit #{id} #{text}"
|
|
120
|
+
else "/objectives #{change} #{id}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The card's Apply: #line behind the prefix ObjectivesCommand reads.
|
|
125
|
+
def action_line(card)
|
|
126
|
+
line.sub("/objectives ", "/objectives --card #{card} #{"--was #{was} " if was}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def title
|
|
130
|
+
case change
|
|
131
|
+
when "add" then "Add an objective?"
|
|
132
|
+
when "reword" then "Reword #{id}?"
|
|
133
|
+
when "done" then "Mark #{id} done?"
|
|
134
|
+
else "Drop #{id}?"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The card's body: what, why, and the line for a terminal, which shows no button.
|
|
139
|
+
def body = [(text unless text.empty?), (note unless note.empty?), "`#{line}`"].compact.join("\n\n")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The list: its items and the number the next id takes (it only grows,
|
|
143
|
+
# so a /objectives set doesn't reuse an id it dropped), and +moves+, the
|
|
144
|
+
# latest Move per id, which #build folds from the tool calls (never in
|
|
145
|
+
# the frame or the payload). Edits answer a new List.
|
|
146
|
+
List = Data.define(:items, :next_number, :moves) do
|
|
147
|
+
def initialize(items: [], next_number: 1, moves: {})
|
|
148
|
+
super
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Nothing was ever set.
|
|
152
|
+
def empty? = items.empty?
|
|
153
|
+
|
|
154
|
+
def find(id) = items.find { |item| item.id == id }
|
|
155
|
+
|
|
156
|
+
# @param text [String]
|
|
157
|
+
# @param done_when [String, nil]
|
|
158
|
+
# @return [List]
|
|
159
|
+
def add(text, done_when: nil)
|
|
160
|
+
with(items: items + [Item.new(id: "O#{next_number}", text: text, done_when: done_when)], next_number: next_number + 1)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A new list in place of this one; the ids go on from this one's.
|
|
164
|
+
# @param entries [Array<Hash>] {text:, done_when:}
|
|
165
|
+
# @return [List]
|
|
166
|
+
def set(entries)
|
|
167
|
+
entries.reduce(with(items: [])) { |list, entry| list.add(entry[:text], done_when: entry[:done_when]) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @return [List]
|
|
171
|
+
def mark(id, status)
|
|
172
|
+
with(items: items.map { |item| item.id == id ? item.with(status: status) : item })
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @return [List]
|
|
176
|
+
def reword(id, text, done_when: nil)
|
|
177
|
+
with(items: items.map { |item| item.id == id ? item.with(text: text, done_when: done_when) : item })
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The note's text: what the model is shown (a dropped one isn't).
|
|
181
|
+
def text
|
|
182
|
+
lines = items.reject { |item| item.status == DROPPED }.map(&:line)
|
|
183
|
+
lines.empty? ? NONE_LEFT : lines.join("\n")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# What the note carries, in one key style: strings, as a save leaves them.
|
|
187
|
+
def to_payload = { "next" => next_number, "items" => items.map(&:to_payload) }
|
|
188
|
+
|
|
189
|
+
# What the page draws: the items, each with its last move and last check,
|
|
190
|
+
# and whether the newest edit is still a file waiting to go in.
|
|
191
|
+
# @param checks [Hash{String => Hash}] ObjectivesCheck.view_of's
|
|
192
|
+
def to_view(pending:, checks: {})
|
|
193
|
+
{ items: items.map { |item| item_view(item, checks[item.id]) }, pending: pending }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def item_view(item, check)
|
|
199
|
+
move = moves[item.id]
|
|
200
|
+
view = item.to_payload
|
|
201
|
+
view = view.merge("move" => { "note" => move.note, "evidence" => move.evidence }) if move
|
|
202
|
+
check ? view.merge("check" => check) : view
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
module_function
|
|
207
|
+
|
|
208
|
+
# One edit's read and write as one step, across threads (each anytime command
|
|
209
|
+
# has its own) and processes: each note holds a whole list, so two edits that
|
|
210
|
+
# read the same list would lose one. The worker's absorb holds it too, so an
|
|
211
|
+
# edit reads neither between the note going into the conversation and its
|
|
212
|
+
# file going. An edit's events go under it: the order is always this lock,
|
|
213
|
+
# then the event log. Nothing takes this under the event log.
|
|
214
|
+
def with_lock(session_dir)
|
|
215
|
+
FileUtils.mkdir_p(session_dir)
|
|
216
|
+
File.open(File.join(session_dir, LOCK_FILE), File::RDWR | File::CREAT, 0o600) do |file|
|
|
217
|
+
file.flock(File::LOCK_EX)
|
|
218
|
+
yield
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# An item's words, short: what a proposed reword was made against.
|
|
223
|
+
def fingerprint(item) = Digest::SHA256.hexdigest("#{item.text}\0#{item.done_when}")[0, 12]
|
|
224
|
+
|
|
225
|
+
# How a proposal card was answered, nil when it wasn't. Under #with_lock.
|
|
226
|
+
def card_state(session_dir, card)
|
|
227
|
+
JSON.parse(File.read(File.join(session_dir, CARDS_FILE)))[card]
|
|
228
|
+
rescue SystemCallError, JSON::ParserError, TypeError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Under #with_lock.
|
|
233
|
+
def record_card(session_dir, card, state)
|
|
234
|
+
path = File.join(session_dir, CARDS_FILE)
|
|
235
|
+
known = begin
|
|
236
|
+
JSON.parse(File.read(path))
|
|
237
|
+
rescue SystemCallError, JSON::ParserError
|
|
238
|
+
{}
|
|
239
|
+
end
|
|
240
|
+
AtomicFile.write(path, JSON.generate(known.merge(card => state)))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The id a call names, as the list spells it.
|
|
244
|
+
def move_id(call) = call[:id].to_s.strip.upcase
|
|
245
|
+
|
|
246
|
+
# A call's text as one line.
|
|
247
|
+
def clean(text) = text.to_s.gsub(/[[:space:]]+/, " ").strip
|
|
248
|
+
|
|
249
|
+
# The list as +conversation+ leaves it, +pending+ over it: a pending file
|
|
250
|
+
# is newer than anything the conversation holds. Pure.
|
|
251
|
+
# @param conversation [Array<Hash>, nil] the stored entries
|
|
252
|
+
# @param pending [Hash, nil] #pending_payload's
|
|
253
|
+
# @param running [Array<Hash>, nil] the running turn's conversation, which
|
|
254
|
+
# +conversation+ (a session's messages) doesn't hold yet: its moves count
|
|
255
|
+
# @return [List]
|
|
256
|
+
def build(conversation, pending: nil, running: nil)
|
|
257
|
+
conversation = Array(conversation)
|
|
258
|
+
list = from_payload(pending) || conversation.reduce(List.new) do |so_far, message|
|
|
259
|
+
(ContextNote.note?(message) && from_payload(ContextNote.fetch(message, :objectives))) || so_far
|
|
260
|
+
end
|
|
261
|
+
list.empty? ? list : list.with(moves: moves_in(conversation).merge(moves_in(Array(running))))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The latest Move per id among the conversation's objectives calls that
|
|
265
|
+
# recorded one (LLMContextStale.paired_runs, as the working set reads its
|
|
266
|
+
# runs: a call_tool's too, both hosts' forms). Tools::ObjectivesTool
|
|
267
|
+
# .recorded? is the check: a call with no list, a refused, a denied or an
|
|
268
|
+
# interrupted one recorded nothing.
|
|
269
|
+
# @return [Hash{String => Move}]
|
|
270
|
+
def moves_in(conversation)
|
|
271
|
+
LLMContextStale.paired_runs(conversation).each_with_object({}) do |run, moves|
|
|
272
|
+
next unless run.call[:name] == Tools::ObjectivesTool::NAME
|
|
273
|
+
|
|
274
|
+
id = move_id(run.call)
|
|
275
|
+
next unless Tools::ObjectivesTool.recorded?(run.text, id)
|
|
276
|
+
|
|
277
|
+
moves[id] = Move.new(id: id, note: clean(run.call[:note]), evidence: clean(run.call[:evidence]), ref: move_ref(conversation, run))
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Whether a note is news from outside the session's own words, for what reads
|
|
282
|
+
# a conversation as evidence or as incoming (ObjectivesCheck, ObjectivesAudit).
|
|
283
|
+
# @param source [String, nil] the note's source
|
|
284
|
+
# @param payload [Object] the note's :objectives
|
|
285
|
+
def outside_note?(source, payload) = payload.nil? && !NOT_OUTSIDE.include?(source)
|
|
286
|
+
|
|
287
|
+
def move_ref(conversation, run)
|
|
288
|
+
called = conversation[0...run.index].reverse_each.find { |entry| entry[:role].to_s == "model" }
|
|
289
|
+
"#{run.ref&.id}@#{called && called[MessageTime::KEY]}"
|
|
290
|
+
end
|
|
291
|
+
private_class_method :move_ref
|
|
292
|
+
|
|
293
|
+
# A session's list now, its unabsorbed newest file included.
|
|
294
|
+
# @return [List]
|
|
295
|
+
def current(session_id, conversation, state_dir:, running: nil)
|
|
296
|
+
build(conversation, pending: pending_payload(Session.session_dir(session_id, state_dir: state_dir)), running: running)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The list as the page draws it (List#to_view), nil with no list: the page
|
|
300
|
+
# shows nothing then.
|
|
301
|
+
# @return [Hash, nil]
|
|
302
|
+
def view(session_id, conversation, state_dir:, running: nil)
|
|
303
|
+
dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
304
|
+
pending = pending_payload(dir)
|
|
305
|
+
list = build(conversation, pending: pending, running: running)
|
|
306
|
+
list.empty? ? nil : list.to_view(pending: !pending.nil?, checks: ObjectivesCheck.view_of(dir, list))
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# One note's list for the page's entry: its items and, when there was a
|
|
310
|
+
# list before it, what changed.
|
|
311
|
+
# @param payload [Object] the note's :objectives
|
|
312
|
+
# @param before [List, nil] the list before this note
|
|
313
|
+
# @return [Hash, nil] nil for what isn't a list
|
|
314
|
+
def entry(payload, before: nil)
|
|
315
|
+
list = from_payload(payload) or return nil
|
|
316
|
+
|
|
317
|
+
fields = { items: list.items.map(&:to_payload) }
|
|
318
|
+
fields[:changes] = changes(before, list).map(&:to_h) if before && !before.empty?
|
|
319
|
+
fields
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# What the user did between two lists, in the new list's order.
|
|
323
|
+
# @return [Array<Change>]
|
|
324
|
+
def changes(before, after)
|
|
325
|
+
(before.items.map(&:id) | after.items.map(&:id)).filter_map do |id|
|
|
326
|
+
change_of(before.find(id), after.find(id))
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# The newest objectives note waiting in +session_dir+'s notes/.
|
|
331
|
+
# @return [Hash, nil] its payload
|
|
332
|
+
def pending_payload(session_dir)
|
|
333
|
+
SessionInbox.find_new_note_files(session_dir).reverse_each do |file|
|
|
334
|
+
payload = SessionInbox.read_note(file)&.dig(:objectives)
|
|
335
|
+
return payload if from_payload(payload)
|
|
336
|
+
end
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# @param payload [Object] a note's :objectives; either key style reads
|
|
341
|
+
# @return [List, nil] nil for what isn't a list (+next+ not an Integer,
|
|
342
|
+
# +items+ not an Array): it can't wipe the fold or restart the ids
|
|
343
|
+
def from_payload(payload)
|
|
344
|
+
return nil unless payload.is_a?(Hash)
|
|
345
|
+
|
|
346
|
+
payload = payload.transform_keys(&:to_s)
|
|
347
|
+
return nil unless payload["next"].is_a?(Integer) && payload["items"].is_a?(Array)
|
|
348
|
+
|
|
349
|
+
items = payload["items"].filter_map { |raw| item_of(raw) }
|
|
350
|
+
highest = items.map { |item| item.id[ID, 1].to_i }.max || 0
|
|
351
|
+
List.new(items: items, next_number: [payload["next"], highest + 1].max)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# A continue's copy: +previous+'s list as a note file in the new link,
|
|
355
|
+
# read fresh, its unabsorbed newest file included. It runs inside the
|
|
356
|
+
# continue's rollback, and a link without the list is still a good link:
|
|
357
|
+
# a failure is logged. Not for a review link, whose next link's
|
|
358
|
+
# participants could differ (as SessionChain.carried_answer).
|
|
359
|
+
# @return [String, nil] the note file's path
|
|
360
|
+
def carry(previous, to:, state_dir:)
|
|
361
|
+
return nil if previous.review?
|
|
362
|
+
|
|
363
|
+
list = current(previous.id, Session.load(previous.id, state_dir: state_dir).messages, state_dir: state_dir)
|
|
364
|
+
return nil if list.empty?
|
|
365
|
+
|
|
366
|
+
SessionInbox.write_note(to, text: list.text, source: SOURCE, objectives: list.to_payload, state_dir: state_dir)
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
Log.warn(:worker, "continue_objectives_failed", sid: previous.id, error: e.class.name, msg: e.message)
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def change_of(was, now)
|
|
373
|
+
return Change.new(id: was.id, kind: DROPPED, text: was.text, was: nil) unless now
|
|
374
|
+
return Change.new(id: now.id, kind: "added", text: now.text, was: nil) unless was
|
|
375
|
+
|
|
376
|
+
if now.text != was.text || now.done_when != was.done_when
|
|
377
|
+
Change.new(id: now.id, kind: "reworded", text: now.text, was: was.text)
|
|
378
|
+
elsif now.status != was.status
|
|
379
|
+
Change.new(id: now.id, kind: now.status == ACTIVE ? "reopened" : now.status, text: now.text, was: nil)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
private_class_method :change_of
|
|
383
|
+
|
|
384
|
+
def item_of(raw)
|
|
385
|
+
return nil unless raw.is_a?(Hash)
|
|
386
|
+
|
|
387
|
+
raw = raw.transform_keys(&:to_s)
|
|
388
|
+
return nil unless ID.match?(raw["id"].to_s) && !raw["text"].to_s.empty?
|
|
389
|
+
|
|
390
|
+
status = STATUSES.include?(raw["status"]) ? raw["status"] : ACTIVE
|
|
391
|
+
Item.new(id: raw["id"].to_s, text: raw["text"].to_s, status: status, done_when: raw["done_when"]&.to_s)
|
|
392
|
+
end
|
|
393
|
+
private_class_method :item_of
|
|
394
|
+
end
|
|
395
|
+
end
|