samagotchi 0.96.0 → 0.98.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 +55 -1
- data/bin/chi +3 -0
- data/docs/architecture.md +12 -2
- data/docs/bundle-authoring.md +7 -4
- data/docs/cli.md +13 -4
- data/docs/configuration.md +13 -0
- data/docs/hooks.md +59 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -2
- data/docs/internals/engine.md +21 -1
- data/docs/memory.md +43 -3
- data/docs/plugins.md +8 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/answer_refs.rb +16 -9
- data/lib/samagotchi/bundle_command/check.rb +5 -3
- data/lib/samagotchi/bundle_command/try/run.rb +19 -4
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +75 -40
- data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
- data/lib/samagotchi/hooks.rb +4 -1
- data/lib/samagotchi/llm/errors.rb +135 -3
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
- data/lib/samagotchi/memory_bundle/profile.rb +14 -4
- data/lib/samagotchi/memory_command.rb +237 -0
- data/lib/samagotchi/output_formatter.rb +8 -2
- data/lib/samagotchi/pending_question.rb +7 -3
- data/lib/samagotchi/plugin/api.rb +12 -3
- data/lib/samagotchi/question_asked.rb +83 -0
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/send_command.rb +11 -11
- data/lib/samagotchi/session_inbox.rb +29 -0
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +1 -0
- data/lib/samagotchi/testing/bundle.rb +18 -1
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -9
- data/lib/samagotchi/web/public/app.js +44 -11
- data/lib/samagotchi/web/public/index.html +36 -13
- data/lib/samagotchi/web/public/info_bar_model.js +0 -2
- data/lib/samagotchi/web/public/lists_view.js +32 -8
- data/lib/samagotchi/web/public/markdown_place.js +25 -8
- data/lib/samagotchi/web/public/question_cards.js +23 -6
- data/lib/samagotchi/web/public/session_rename.js +88 -15
- data/lib/samagotchi/web/public/session_title.js +6 -0
- data/lib/samagotchi/web/public/stage_frame.js +24 -14
- data/lib/samagotchi/web/public/stage_model.js +4 -2
- data/lib/samagotchi/web/public/stage_slots.js +41 -9
- data/lib/samagotchi/web/public/stage_view.js +149 -8
- data/lib/samagotchi/web/public/stream_handlers.js +5 -1
- data/lib/samagotchi/web/public/turn_model.js +20 -0
- data/lib/samagotchi/web/public/turn_view.js +22 -9
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_idle_exit.rb +16 -8
- metadata +3 -1
|
@@ -6,15 +6,22 @@ module Samagotchi
|
|
|
6
6
|
module MemoryBundle
|
|
7
7
|
# One hook's metadata, as a bundle's manifest.yml declares it and its
|
|
8
8
|
# installed record (manifest.json) keeps it: the file's sha256
|
|
9
|
-
# ("sha256:<hex>", "" when none is recorded), the
|
|
10
|
-
# for (
|
|
11
|
-
#
|
|
12
|
-
|
|
9
|
+
# ("sha256:<hex>", "" when none is recorded), the events it registers
|
|
10
|
+
# for (manifest `event:` for one, `events:` a list; none: it isn't
|
|
11
|
+
# loaded), on_error (skip, log or fail_closed) and priority. One
|
|
12
|
+
# instance of the hook serves every event it lists. The one place the
|
|
13
|
+
# defaults live.
|
|
14
|
+
class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
|
|
13
15
|
DEFAULT_ON_ERROR = "skip"
|
|
14
16
|
DEFAULT_PRIORITY = 100
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
# @param event [String, nil] one event (manifest `event:`)
|
|
19
|
+
# @param events [Array<String>, String, nil] the events (manifest
|
|
20
|
+
# `events:`); `event:` and `events:` together are joined, blanks
|
|
21
|
+
# and repeats dropped
|
|
22
|
+
def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
|
|
23
|
+
names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
24
|
+
super(sha256: sha256, events: names, on_error: on_error, priority: priority)
|
|
18
25
|
end
|
|
19
26
|
|
|
20
27
|
# @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
|
|
@@ -25,7 +32,7 @@ module Samagotchi
|
|
|
25
32
|
|
|
26
33
|
h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
|
|
27
34
|
on_error = h["on_error"].to_s.strip
|
|
28
|
-
new(sha256: prefixed(h["sha256"]), event: h["event"]
|
|
35
|
+
new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
|
|
29
36
|
on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
|
|
30
37
|
priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
|
|
31
38
|
end
|
|
@@ -44,8 +51,20 @@ module Samagotchi
|
|
|
44
51
|
# The sha's hex, "" when none is recorded.
|
|
45
52
|
def hex = sha256.delete_prefix("sha256:")
|
|
46
53
|
|
|
47
|
-
#
|
|
48
|
-
|
|
54
|
+
# Whether the hook fails closed: on_error fail_closed applies to its
|
|
55
|
+
# before_tool_call registration only.
|
|
56
|
+
def fail_closed?(event) = on_error == "fail_closed" && event.to_s == "before_tool_call"
|
|
57
|
+
|
|
58
|
+
# How `chi bundle status` / `diff` name its events: `event=x`, or
|
|
59
|
+
# `events=x,y` for more than one.
|
|
60
|
+
def events_field = events.size > 1 ? "events=#{events.join(",")}" : "event=#{events.first}"
|
|
61
|
+
|
|
62
|
+
# The string-keyed form manifest.yml and manifest.json hold: `event`
|
|
63
|
+
# for one event (as an older chi reads it), `events` for more.
|
|
64
|
+
def to_record
|
|
65
|
+
listed = events.size > 1 ? { "events" => events.dup } : { "event" => events.first.to_s }
|
|
66
|
+
{ "sha256" => sha256, **listed, "on_error" => on_error, "priority" => priority }
|
|
67
|
+
end
|
|
49
68
|
end
|
|
50
69
|
end
|
|
51
70
|
end
|
|
@@ -375,9 +375,26 @@ module Samagotchi
|
|
|
375
375
|
next unless k.is_a?(String) && !k.empty?
|
|
376
376
|
next unless v.is_a?(Hash)
|
|
377
377
|
|
|
378
|
+
check_hook_events(k, v)
|
|
378
379
|
acc[k] = BundleHook.parse(v)
|
|
379
380
|
end
|
|
380
381
|
end
|
|
382
|
+
|
|
383
|
+
HOOK_EVENT_NAME = /\A[a-z][a-z0-9_]*\z/
|
|
384
|
+
|
|
385
|
+
# A hook's events: list holds event names (one instance registered
|
|
386
|
+
# for each); it and event: are one or the other.
|
|
387
|
+
def check_hook_events(file, entry)
|
|
388
|
+
return unless entry.key?("events")
|
|
389
|
+
|
|
390
|
+
raw = entry["events"]
|
|
391
|
+
raise ValidationError, "hooks: #{file}: give event: or events:, not both" if entry.key?("event")
|
|
392
|
+
raise ValidationError, "hooks: #{file}: events: must be a list of event names" unless raw.is_a?(Array) && !raw.empty?
|
|
393
|
+
|
|
394
|
+
raw.each do |item|
|
|
395
|
+
raise ValidationError, "hooks: #{file}: events: #{item.inspect} is not an event name" unless item.is_a?(String) && item.strip.match?(HOOK_EVENT_NAME)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
381
398
|
end
|
|
382
399
|
end
|
|
383
400
|
end
|
|
@@ -80,7 +80,7 @@ module Samagotchi
|
|
|
80
80
|
# "Shipped notes").
|
|
81
81
|
def kept_own_line(member, file)
|
|
82
82
|
"#{member}: kept your own #{file} (not the bundle's; to take the bundle's, remove yours with " \
|
|
83
|
-
"
|
|
83
|
+
"chi memory rm #{file.delete_suffix(".md")} --scope system, then chi bundle install #{member})"
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
# The one rule.
|
|
@@ -121,19 +121,29 @@ module Samagotchi
|
|
|
121
121
|
member_dir = File.join(shipped_dir, member)
|
|
122
122
|
failure = Manifest.requires_chi_failure(Manifest.read(dir: member_dir).requires_chi, chi_version)
|
|
123
123
|
return result.skipped[member] = failure if failure
|
|
124
|
-
return result.installed << member if dry_run
|
|
125
124
|
|
|
125
|
+
# A dry run runs the Installer's too, which writes nothing, so it
|
|
126
|
+
# names the files the real run would keep as the user's own.
|
|
126
127
|
installer = Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true,
|
|
127
|
-
shipped_dir: shipped_dir)
|
|
128
|
+
shipped_dir: shipped_dir, dry_run: dry_run)
|
|
128
129
|
installer.run
|
|
129
130
|
result.installed << member
|
|
130
|
-
kept = installer.results.select { |_, entry| entry
|
|
131
|
+
kept = installer.results.select { |_, entry| kept_own?(entry) }.keys
|
|
131
132
|
result.kept_own[member] = kept.sort unless kept.empty?
|
|
132
133
|
rescue StandardError => e
|
|
133
134
|
result.failed[member] = e.message.lines.first.to_s.strip
|
|
134
135
|
end
|
|
135
136
|
private_class_method :install_member
|
|
136
137
|
|
|
138
|
+
# An Installer result for a file the user already had, with other
|
|
139
|
+
# bytes: skipped ("already exists"), or a dry run's would_skip. One
|
|
140
|
+
# with the bundle's bytes ("already up to date") isn't named: nothing
|
|
141
|
+
# of the bundle's is missing (it stays the user's all the same).
|
|
142
|
+
def kept_own?(entry)
|
|
143
|
+
entry[:status] == "would_skip" || (entry[:status] == "skipped" && entry[:reason] == "already exists")
|
|
144
|
+
end
|
|
145
|
+
private_class_method :kept_own?
|
|
146
|
+
|
|
137
147
|
# Uninstalls each recorded member still installed, then the meta. A
|
|
138
148
|
# member that won't go (an edited .md file, without +force+) is
|
|
139
149
|
# reported and the rest carry on; the meta then stays, still
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "cli/command"
|
|
5
|
+
require_relative "cli/exit"
|
|
6
|
+
require_relative "cli/flags"
|
|
7
|
+
require_relative "memory_paths"
|
|
8
|
+
require_relative "tools/memory"
|
|
9
|
+
require_relative "memory_bundle/index_updater"
|
|
10
|
+
require_relative "memory_bundle/provenance"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# `chi memory`: the user's side of the memories (docs/memory.md). ls lists
|
|
14
|
+
# both scopes' entries from their index.md lines and files, as memory_read
|
|
15
|
+
# and the prompt see them; rm removes one as memory_write remove: true does
|
|
16
|
+
# (Tools::MemoryWrite.remove: the file and its model overlays into the
|
|
17
|
+
# bundle trash, the index line dropped, a bundle's memory refused).
|
|
18
|
+
class MemoryCommand
|
|
19
|
+
include CLI::Command
|
|
20
|
+
|
|
21
|
+
USAGE = <<~TEXT
|
|
22
|
+
Usage: chi memory <ls|rm> [options]
|
|
23
|
+
ls [--scope project|system] [--format text|json] the memories: both scopes (this folder's project
|
|
24
|
+
first), or one; each with its size, date, the bundle it came from and its index description
|
|
25
|
+
rm NAME [--scope project|system] remove a memory: its file and its model overlays (NAME.<key>.md)
|
|
26
|
+
move to the bundle trash (chi bundle trash lists and empties it) and its index line is dropped.
|
|
27
|
+
Without --scope NAME must be in one scope only. A bundle's memory is refused: chi bundle
|
|
28
|
+
uninstall removes it. NAME.<key> removes one model overlay alone.
|
|
29
|
+
TEXT
|
|
30
|
+
|
|
31
|
+
SUBCOMMANDS = %w[ls rm].freeze
|
|
32
|
+
SCOPES = Tools::VALID_SCOPES
|
|
33
|
+
# The description column's width in ls's text form (json has it all).
|
|
34
|
+
DESCRIPTION_WIDTH = 60
|
|
35
|
+
# ls's size column, right-aligned.
|
|
36
|
+
SIZE_COLUMN = 2
|
|
37
|
+
|
|
38
|
+
FLAGS = {
|
|
39
|
+
"ls" => CLI::Flags.new(help: HELP_WORDS) do |f|
|
|
40
|
+
f.value "--scope"
|
|
41
|
+
f.value "--format"
|
|
42
|
+
end,
|
|
43
|
+
"rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--scope" }
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# One memory of a scope. +date+ is the index line's (the file's mtime
|
|
47
|
+
# when it has none); +source+ the bundle that owns it; +overlays+ the
|
|
48
|
+
# model keys of its overlays; +indexed+ / +file+: whether it has an
|
|
49
|
+
# index line / a file (a line whose file was deleted by hand has none).
|
|
50
|
+
Entry = Data.define(:name, :scope, :path, :bytes, :date, :source, :description, :overlays, :indexed, :file) do
|
|
51
|
+
def to_h = super.transform_keys(&:to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A managed index line: "- **name** · scope · date · size[ · from b][ — desc]"
|
|
55
|
+
# or a legacy "- **name**: desc" (MemoryWrite.managed_pattern).
|
|
56
|
+
LINE = /^- \*\*([^*\n]+)\*\*[ \t]*(?:[·•][^\n]*|:[^\n]*)?$/
|
|
57
|
+
|
|
58
|
+
# @param argv [Array<String>] the arguments after "memory"
|
|
59
|
+
# The project scope is the cwd's project, as for the memory tools.
|
|
60
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
61
|
+
@argv = argv.dup
|
|
62
|
+
@stdout = stdout
|
|
63
|
+
@stderr = stderr
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
|
|
67
|
+
def run
|
|
68
|
+
@sub = @argv[0]
|
|
69
|
+
if @sub.nil? || HELP_WORDS.include?(@sub)
|
|
70
|
+
@stdout.puts(USAGE)
|
|
71
|
+
return CLI::Exit::OK
|
|
72
|
+
end
|
|
73
|
+
return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
|
|
74
|
+
|
|
75
|
+
parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
|
|
76
|
+
return parsed if parsed.is_a?(Integer)
|
|
77
|
+
|
|
78
|
+
scope = parsed.options[:scope]
|
|
79
|
+
return usage_error("unknown scope #{scope.inspect}: use --scope project|system") if scope && !SCOPES.include?(scope)
|
|
80
|
+
|
|
81
|
+
send(:"run_#{@sub}", parsed.options, parsed.args)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The memories of +scope+ (project first, then system, when nil), by
|
|
85
|
+
# name within a scope.
|
|
86
|
+
# @return [Array<Entry>]
|
|
87
|
+
def self.entries(scope = nil)
|
|
88
|
+
owners = MemoryBundle::Provenance.each_installed.reject { |_, bundle| bundle.error? }
|
|
89
|
+
(scope ? [scope] : SCOPES).flat_map { |s| scope_entries(s, owners) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.scope_entries(scope, owners)
|
|
93
|
+
dir = MemoryPaths.scope_dir(scope)
|
|
94
|
+
return [] unless File.directory?(dir)
|
|
95
|
+
|
|
96
|
+
index_path = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
|
|
97
|
+
index_text = File.file?(index_path) ? File.read(index_path, encoding: "UTF-8") : ""
|
|
98
|
+
# name => its line; the first one when a name has two. A name rm
|
|
99
|
+
# would refuse (a path: "../x") isn't listed.
|
|
100
|
+
lines = {}
|
|
101
|
+
index_text.each_line do |l|
|
|
102
|
+
(m = l.chomp.match(LINE)) or next
|
|
103
|
+
lines[m[1]] ||= m[0] unless Tools::MemoryRead.invalid_name?(m[1])
|
|
104
|
+
end
|
|
105
|
+
stems = Dir.children(dir).filter_map { |f| f.delete_suffix(".md") if f.end_with?(".md") && File.file?(File.join(dir, f)) }.to_set
|
|
106
|
+
overlays = overlays_by_base(stems, lines)
|
|
107
|
+
overlay_stems = overlays.values.flatten(1).to_set { |_key, stem| stem }
|
|
108
|
+
names = (lines.keys + stems.to_a).uniq - [Tools::MEMORY_INDEX]
|
|
109
|
+
names.filter_map do |name|
|
|
110
|
+
next if overlay_stems.include?(name)
|
|
111
|
+
|
|
112
|
+
path = File.join(dir, "#{name}.md")
|
|
113
|
+
line = lines[name]
|
|
114
|
+
file = stems.include?(name)
|
|
115
|
+
# A bold line of prose ("- **Important**") isn't a memory: only a
|
|
116
|
+
# managed line ("· …") counts without a file.
|
|
117
|
+
next unless file || line&.include?(" · ")
|
|
118
|
+
|
|
119
|
+
entry(name, scope, path, line, file, overlays.fetch(name, []).map(&:first), owners)
|
|
120
|
+
end.sort_by(&:name)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# base name => [[key, stem]...] of its model overlays, in one pass over
|
|
124
|
+
# the files (+stems+, a Set): <base>.<key>.md with a key ModelOverlay
|
|
125
|
+
# makes, whose base is a file and whose stem has no index line of its
|
|
126
|
+
# own (MemoryWrite.overlay_files' rule, for every base at once).
|
|
127
|
+
def self.overlays_by_base(stems, lines)
|
|
128
|
+
stems.sort.each_with_object({}) do |stem, by_base|
|
|
129
|
+
base, dot, key = stem.rpartition(".")
|
|
130
|
+
next if dot.empty? || base.empty? || !key.match?(/\A[a-z0-9-]+\z/)
|
|
131
|
+
next unless stems.include?(base) && !lines.key?(stem)
|
|
132
|
+
|
|
133
|
+
(by_base[base] ||= []) << [key, stem]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.entry(name, scope, path, line, file, overlay_keys, owners)
|
|
138
|
+
source = line && MemoryBundle::IndexUpdater.extract_source(line)
|
|
139
|
+
source ||= owners.find { |_, bundle| bundle.effective_scope == scope && bundle.owns?("#{name}.md") }&.first
|
|
140
|
+
Entry.new(
|
|
141
|
+
name: name, scope: scope, path: path, bytes: file ? File.size(path) : nil,
|
|
142
|
+
date: line.to_s[/ · (\d{4}-\d{2}-\d{2})\b/, 1] || (file ? File.mtime(path).strftime("%Y-%m-%d") : nil),
|
|
143
|
+
source: source, description: line && line.include?(" · ") ? line.split(" — ", 2)[1]&.strip : legacy(line),
|
|
144
|
+
overlays: overlay_keys,
|
|
145
|
+
indexed: !line.nil?, file: file
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A legacy "- **name**: desc" line's description.
|
|
150
|
+
def self.legacy(line) = line&.split("**:", 2)&.[](1)&.strip&.then { |d| d.empty? ? nil : d }
|
|
151
|
+
private_class_method :scope_entries, :overlays_by_base, :entry, :legacy
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi memory #{@sub}" : "chi memory"
|
|
156
|
+
def usage_on_error = nil
|
|
157
|
+
|
|
158
|
+
def run_ls(options, args)
|
|
159
|
+
return usage_error("ls takes no NAME (unexpected #{args.first})") unless args.empty?
|
|
160
|
+
|
|
161
|
+
format = options[:format]
|
|
162
|
+
return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
|
|
163
|
+
|
|
164
|
+
rows = self.class.entries(options[:scope])
|
|
165
|
+
if format == "json"
|
|
166
|
+
@stdout.puts(JSON.pretty_generate(rows.map(&:to_h)))
|
|
167
|
+
elsif rows.empty?
|
|
168
|
+
@stdout.puts("no memories#{" in #{options[:scope]} scope" if options[:scope]}")
|
|
169
|
+
else
|
|
170
|
+
print_rows(rows)
|
|
171
|
+
end
|
|
172
|
+
CLI::Exit::OK
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def print_rows(rows)
|
|
176
|
+
table = rows.map do |r|
|
|
177
|
+
notes = []
|
|
178
|
+
notes << "overlays: #{r.overlays.join(", ")}" unless r.overlays.empty?
|
|
179
|
+
notes << "no file" unless r.file
|
|
180
|
+
notes << "no index line" unless r.indexed
|
|
181
|
+
[r.scope, r.name, r.bytes.to_s, r.date.to_s, r.source ? "from #{r.source}" : "",
|
|
182
|
+
[clip(r.description.to_s), *notes.map { |n| "[#{n}]" }].reject(&:empty?).join(" ")]
|
|
183
|
+
end
|
|
184
|
+
widths = table.transpose.map { |column| column.map(&:length).max }
|
|
185
|
+
# A column no row fills (no memory from a bundle) takes no room.
|
|
186
|
+
table.each do |line|
|
|
187
|
+
cells = line.each_with_index.map { |cell, i| i == SIZE_COLUMN ? cell.rjust(widths[i]) : cell.ljust(widths[i]) }
|
|
188
|
+
@stdout.puts(cells.reject.with_index { |_, i| widths[i].zero? }.join(" ").rstrip)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def clip(text) = text.length > DESCRIPTION_WIDTH ? "#{text[0, DESCRIPTION_WIDTH - 1]}…" : text
|
|
193
|
+
|
|
194
|
+
def run_rm(options, args)
|
|
195
|
+
name = args.shift
|
|
196
|
+
return usage_error("give the memory's NAME") unless name
|
|
197
|
+
return usage_error("rm takes one NAME (unexpected #{args.first})") unless args.empty?
|
|
198
|
+
|
|
199
|
+
scope = options[:scope] || only_scope(name)
|
|
200
|
+
return scope if scope.is_a?(Integer)
|
|
201
|
+
|
|
202
|
+
result = Tools::MemoryWrite.call(nil, path: name, scope: scope, remove: true)
|
|
203
|
+
if result.start_with?("Error: ")
|
|
204
|
+
error_line("#{command_name}: #{result.delete_prefix("Error: ")}")
|
|
205
|
+
return CLI::Exit::FAILED
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
@stdout.puts(result)
|
|
209
|
+
CLI::Exit::OK
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The one scope +name+ is in (a file or an index line), else the exit
|
|
213
|
+
# status: a usage error when it is in both, a failure in neither (an
|
|
214
|
+
# invalid name goes on to remove's own error).
|
|
215
|
+
# @return [String, Integer]
|
|
216
|
+
def only_scope(name)
|
|
217
|
+
return "project" if Tools::MemoryRead.invalid_name?(name) || name == Tools::MEMORY_INDEX
|
|
218
|
+
|
|
219
|
+
found = SCOPES.select { |scope| present?(name, scope) }
|
|
220
|
+
case found.size
|
|
221
|
+
when 1 then found.first
|
|
222
|
+
when 0
|
|
223
|
+
error_line("#{command_name}: no memory '#{name}' in project or system scope")
|
|
224
|
+
CLI::Exit::FAILED
|
|
225
|
+
else
|
|
226
|
+
usage_error("#{name} is in both scopes: chi memory rm #{name} --scope project, or --scope system")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def present?(name, scope)
|
|
231
|
+
dir = MemoryPaths.scope_dir(scope)
|
|
232
|
+
index = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
|
|
233
|
+
File.file?(File.join(dir, "#{name}.md")) ||
|
|
234
|
+
(File.file?(index) && File.read(index, encoding: "UTF-8").match?(Tools::MemoryWrite.managed_pattern(name)))
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -65,6 +65,10 @@ module Samagotchi
|
|
|
65
65
|
# Qwen tool_call XML blocks that survive when a tool call is rendered as text
|
|
66
66
|
# e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
|
|
67
67
|
QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
|
|
68
|
+
# Gemma 4's closed tool-call block, <|tool_call>call:NAME{…}<tool_call|>:
|
|
69
|
+
# a native step's call, whose body #strip_markup drops (#strip keeps it:
|
|
70
|
+
# a streamed chunk may hold only one marker).
|
|
71
|
+
GEMMA_TOOL_CALL_RE = /<\|tool_call>.*?<tool_call\|>/m
|
|
68
72
|
|
|
69
73
|
module_function
|
|
70
74
|
|
|
@@ -85,9 +89,11 @@ module Samagotchi
|
|
|
85
89
|
|
|
86
90
|
# The same tokens as #strip, but the text keeps its layout (indentation,
|
|
87
91
|
# runs of spaces); only the blank lines removed blocks leave collapse.
|
|
88
|
-
# For showing a whole saved message (the attached TUI's join, the web history)
|
|
92
|
+
# For showing a whole saved message (the attached TUI's join, the web history):
|
|
93
|
+
# a native Gemma step's closed tool-call blocks go with their bodies, as
|
|
94
|
+
# Qwen's do (a step that only calls a tool has no text).
|
|
89
95
|
def strip_markup(text)
|
|
90
|
-
remove_tokens(text).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
|
|
96
|
+
remove_tokens(text.to_s.gsub(GEMMA_TOOL_CALL_RE, "")).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
|
|
91
97
|
end
|
|
92
98
|
|
|
93
99
|
def remove_tokens(text)
|
|
@@ -17,15 +17,19 @@ module Samagotchi
|
|
|
17
17
|
# relayed approval's facts (Relay.card); limit, context: the step-limit
|
|
18
18
|
# question's turn limit and offer context (ContinueOffer); status:
|
|
19
19
|
# "pending" or "cancelled"; created_at: iso8601; relayed_to: the relay
|
|
20
|
-
# mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate)
|
|
21
|
-
#
|
|
20
|
+
# mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate);
|
|
21
|
+
# refs: the refs on the reply that asks ({match:, kind:, target:, …},
|
|
22
|
+
# the :question_asked hooks', QuestionAsked; display only, checked again
|
|
23
|
+
# where a saved one reaches a UI).
|
|
24
|
+
# approval, relay, context, relayed_to and refs stay as given (string
|
|
22
25
|
# keys inside after a load). A field may be nil (unset).
|
|
23
26
|
#
|
|
24
27
|
# Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
|
|
25
28
|
# a UI (symbol keys), #to_file for session.json (string keys); both in
|
|
26
29
|
# member order with unset fields left out. .from_file reads either back.
|
|
27
30
|
PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :effects, :user_only,
|
|
28
|
-
:kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to
|
|
31
|
+
:kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to,
|
|
32
|
+
:refs) do
|
|
29
33
|
def initialize(**fields)
|
|
30
34
|
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
31
35
|
end
|
|
@@ -21,8 +21,17 @@ module Samagotchi
|
|
|
21
21
|
# The first chi release with each call a plugin may make (from git:
|
|
22
22
|
# the first release tag holding it), for `chi bundle check`'s
|
|
23
23
|
# requires-chi-floor: a bundle whose requires_chi admits an older chi.
|
|
24
|
-
# A call not listed gets no floor check: it
|
|
25
|
-
#
|
|
24
|
+
# A call not listed gets no floor check: it is in the first tag (v0.2.0:
|
|
25
|
+
# chi.command/tool/on/service/init, ctx.settings, ctx.sessions,
|
|
26
|
+
# ctx.ask_model, ctx.notify, ctx.card, ctx.messages…). A member of
|
|
27
|
+
# one (ctx.sessions.children) is listed when it came later. Keys have
|
|
28
|
+
# no "?" (API_CALL drops it). A keyword added later (ctx.ask_model's
|
|
29
|
+
# model: in 0.80.0, ctx.sessions.send's delivery: in 0.64.0) has none.
|
|
30
|
+
# Events aren't listed: chi.on takes any symbol and an older chi never
|
|
31
|
+
# fires one it doesn't know (harmless; chi bundle check warns on it),
|
|
32
|
+
# and a manifest hook's events: list (BundleHook) needs the bundle's
|
|
33
|
+
# requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
|
|
34
|
+
# scan covers manifest.yml.
|
|
26
35
|
SINCE = {
|
|
27
36
|
"ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
|
|
28
37
|
"Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
|
|
@@ -31,7 +40,7 @@ module Samagotchi
|
|
|
31
40
|
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
32
41
|
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
33
42
|
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
34
|
-
"ctx.cost" => "0.83.0"
|
|
43
|
+
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
|
|
35
44
|
}.freeze
|
|
36
45
|
|
|
37
46
|
# @param bundle [String]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "answer_refs"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "output_formatter"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The :question_asked hook event: a question of the model's own (its
|
|
9
|
+
# ask_user_question, kind "question") or an approval of its tool call
|
|
10
|
+
# (kind "approval") is about to open. Fired from the two Engine seams
|
|
11
|
+
# (Engine#request_question, the guardrail wiring's ask:), never from
|
|
12
|
+
# QuestionDesk, so a relayed delegate approval, a hook's ask_user and the
|
|
13
|
+
# step-limit question never fire it.
|
|
14
|
+
#
|
|
15
|
+
# A hook attaches refs to the reply that asks (event[:refs], after_turn's
|
|
16
|
+
# call shape and checks, AnswerRefs): they are kept as `display_refs` on
|
|
17
|
+
# the step's model message (the live conversation's last model entry,
|
|
18
|
+
# changed in place: the loops and the forget layer hold it by identity),
|
|
19
|
+
# display only, and go on the pending question (PendingQuestion#refs), so
|
|
20
|
+
# a UI pairs them in the asking text, on the card and in the history.
|
|
21
|
+
#
|
|
22
|
+
# The hooks run on the turn thread before the question opens: their time
|
|
23
|
+
# is the question's wait. A raising hook is logged (Registry#fire) and the
|
|
24
|
+
# question opens without its refs; event[:ask_user] is a no-op (nil).
|
|
25
|
+
module QuestionAsked
|
|
26
|
+
EVENT = :question_asked
|
|
27
|
+
KIND_QUESTION = "question"
|
|
28
|
+
KIND_APPROVAL = "approval"
|
|
29
|
+
# Lower than an answer's (AnswerRefs): cards.json holds up to 20 cards.
|
|
30
|
+
MAX_REFS = 50
|
|
31
|
+
MAX_BYTES = 32 * 1024
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Fire the event and return the question's fields with the step's refs
|
|
36
|
+
# (+fields+ as given when it has none, or with no subscriber: then
|
|
37
|
+
# nothing is built).
|
|
38
|
+
# @param hooks [Hooks::Registry]
|
|
39
|
+
# @param conversation [Array<Hash>, nil] the running turn's live
|
|
40
|
+
# conversation (Engine#live_conversation)
|
|
41
|
+
# @param kind [String] KIND_QUESTION or KIND_APPROVAL
|
|
42
|
+
# @param fields [Hash] the question's fields (no id yet)
|
|
43
|
+
# @param cwd [String, nil] the session's folder, for file refs
|
|
44
|
+
# @param synchronize [#call, nil] runs the step's change under the
|
|
45
|
+
# event lock (a snapshot reads the conversation under it)
|
|
46
|
+
# @param visible [#call, nil] (content) → the step's text as the asking
|
|
47
|
+
# slot shows it; default OutputFormatter.strip_markup (the Engine's
|
|
48
|
+
# drops the native loop's tool-call bodies first)
|
|
49
|
+
# @return [Hash] the fields to open the question with
|
|
50
|
+
def fire(hooks:, conversation:, kind:, fields:, cwd:, synchronize: nil, visible: nil)
|
|
51
|
+
return fields unless hooks&.any?(EVENT)
|
|
52
|
+
|
|
53
|
+
step = Array(conversation).reverse_each.find { |m| m.is_a?(Hash) && (m[:role] || m["role"]).to_s == "model" }
|
|
54
|
+
refs = AnswerRefs.new(step ? [step] : [], cwd: cwd, max_refs: MAX_REFS, max_bytes: MAX_BYTES)
|
|
55
|
+
visible ||= ->(content) { OutputFormatter.strip_markup(content) }
|
|
56
|
+
event = { type: EVENT, kind: kind, text: step ? visible.call((step[:content] || step["content"]).to_s) : "",
|
|
57
|
+
question: deep_freeze(Marshal.load(Marshal.dump(fields.to_h))),
|
|
58
|
+
# A question from here would wait inside the one opening.
|
|
59
|
+
ask_user: ->(**) {} }
|
|
60
|
+
event[:refs] = refs.adder(event)
|
|
61
|
+
hooks.fire(EVENT, event)
|
|
62
|
+
refs.close!
|
|
63
|
+
list = refs.to_a
|
|
64
|
+
if refs.added?
|
|
65
|
+
store = -> { step[AnswerRefs::KEY] = list }
|
|
66
|
+
synchronize ? synchronize.call(&store) : store.call
|
|
67
|
+
end
|
|
68
|
+
# The step's whole list: a second question in it gets the first's too.
|
|
69
|
+
list.empty? ? fields : fields.merge(refs: list)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
Log.warn(:hooks, "question_asked_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
72
|
+
fields
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deep_freeze(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash then value.each_value { |v| deep_freeze(v) }
|
|
78
|
+
when Array then value.each { |v| deep_freeze(v) }
|
|
79
|
+
end
|
|
80
|
+
value.freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -120,10 +120,11 @@ module Samagotchi
|
|
|
120
120
|
# question_handler, called on the turn thread with the payload
|
|
121
121
|
# Tools::AskUserQuestion.validate made. Opens it (#open_question) and
|
|
122
122
|
# returns the answer as JSON for the tool result.
|
|
123
|
-
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
123
|
+
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:},
|
|
124
|
+
# and refs: (the asking step's, QuestionAsked) when it has any
|
|
124
125
|
# @return [String] normalized answer JSON
|
|
125
126
|
def request(payload)
|
|
126
|
-
result = open_question(payload.slice(:question, :options, :header)
|
|
127
|
+
result = open_question(payload.slice(:question, :options, :header, :refs)
|
|
127
128
|
.merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
|
|
128
129
|
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
129
130
|
# tool failure the model learns to avoid the tool from.
|
|
@@ -685,13 +685,12 @@ module Samagotchi
|
|
|
685
685
|
return true
|
|
686
686
|
end
|
|
687
687
|
|
|
688
|
-
ack = result.ack
|
|
689
688
|
@delivered = result
|
|
690
689
|
# What the worker applied, for --format json: its ack's delivery
|
|
691
690
|
# (next_step for an idle session) and a cut's outcome.
|
|
692
|
-
@sent_delivery =
|
|
693
|
-
@sent_cut =
|
|
694
|
-
@info.puts("#{short} sent#{with_images}#{delivery_note(
|
|
691
|
+
@sent_delivery = result.delivery || delivery
|
|
692
|
+
@sent_cut = result.cut
|
|
693
|
+
@info.puts("#{short} sent#{with_images}#{delivery_note(result, delivery: delivery, running: running, owner: owner)}")
|
|
695
694
|
true
|
|
696
695
|
rescue SessionManager::OwnedByTUI
|
|
697
696
|
@info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
|
|
@@ -706,11 +705,12 @@ module Samagotchi
|
|
|
706
705
|
# the outcome from the ack (now, waits, off); a --queue one runs after
|
|
707
706
|
# the turn (or now, when the turn ended before it went in); images keep
|
|
708
707
|
# their own wording; an idle session's turn simply runs.
|
|
709
|
-
# @param
|
|
708
|
+
# @param result [SessionManager::DeliverResult] the accepted delivery
|
|
709
|
+
# (its ack is {} for the input file)
|
|
710
710
|
# @return [String] "" or " (...)"
|
|
711
|
-
def delivery_note(
|
|
711
|
+
def delivery_note(result, delivery:, running:, owner:)
|
|
712
712
|
return " (started its worker)" if owner.nil?
|
|
713
|
-
return queue_note(
|
|
713
|
+
return queue_note(result, running: running) if Delivery.queue?(delivery)
|
|
714
714
|
return "" unless running
|
|
715
715
|
|
|
716
716
|
# A busy worker runs a message with images as its own next turn
|
|
@@ -720,18 +720,18 @@ module Samagotchi
|
|
|
720
720
|
# The TUIs' words for a cut's outcome. No outcome: a plain message,
|
|
721
721
|
# or the input file (the Bridge went away), which the next step's
|
|
722
722
|
# drain takes.
|
|
723
|
-
outcome = Delivery.cut?(delivery) ?
|
|
723
|
+
outcome = Delivery.cut?(delivery) ? result.cut.to_s : ""
|
|
724
724
|
return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
|
|
725
725
|
|
|
726
|
-
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym,
|
|
726
|
+
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, result.cut_after)}"
|
|
727
727
|
end
|
|
728
728
|
|
|
729
729
|
# A --queue, worded from the Bridge's ack: queue when a turn ran as it
|
|
730
730
|
# went in (one another client started too), next_step when none did
|
|
731
731
|
# (it runs now). The input file (no Bridge) carries the queue to
|
|
732
732
|
# whichever turn runs: worded from the session as chi read it.
|
|
733
|
-
def queue_note(
|
|
734
|
-
queued =
|
|
733
|
+
def queue_note(result, running:)
|
|
734
|
+
queued = result.delivery.nil? ? running : result.delivery == Delivery::QUEUE
|
|
735
735
|
return " (queued: runs after the current turn)" if queued
|
|
736
736
|
|
|
737
737
|
running ? " (the running turn had ended: it runs now)" : " (nothing running: it runs now)"
|
|
@@ -359,6 +359,35 @@ module Samagotchi
|
|
|
359
359
|
end
|
|
360
360
|
private_class_method :unmark_removed
|
|
361
361
|
|
|
362
|
+
# How old a per-input tombstone (input/removed/, TurnCheckpoint's
|
|
363
|
+
# input/settled/) gets before a worker's start prunes it (#prune_old).
|
|
364
|
+
# A wait reads one at its next poll (ReplyWait::POLL_INTERVAL) and a
|
|
365
|
+
# later `chi send --wait --turn` reads a settled one at its start, so
|
|
366
|
+
# only a wait whose process slept (a closed laptop) or one started long
|
|
367
|
+
# after needs it to stay. A removed one gone under a live wait would
|
|
368
|
+
# read as a merge (the wrong reply), so the age is generous: a week,
|
|
369
|
+
# half the session retention (14 days), for one small file per
|
|
370
|
+
# file-delivered command or dequeue.
|
|
371
|
+
TOMBSTONE_MAX_AGE = 7 * 24 * 3600
|
|
372
|
+
|
|
373
|
+
# Deletes the files in input/<+subdir+> last written more than
|
|
374
|
+
# +max_age+ seconds ago; one that can't be read or deleted stays.
|
|
375
|
+
# @return [Integer] how many went
|
|
376
|
+
def self.prune_old(session_dir, subdir, max_age: TOMBSTONE_MAX_AGE)
|
|
377
|
+
cutoff = Time.now - max_age
|
|
378
|
+
Dir.glob(File.join(session_dir, INPUT_DIR, subdir, "*")).count do |file|
|
|
379
|
+
next false unless File.mtime(file) < cutoff
|
|
380
|
+
|
|
381
|
+
File.delete(file)
|
|
382
|
+
true
|
|
383
|
+
rescue SystemCallError
|
|
384
|
+
false
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# The removed tombstones past +max_age+ (#prune_old).
|
|
389
|
+
def self.prune_removed(session_dir, max_age: TOMBSTONE_MAX_AGE) = prune_old(session_dir, REMOVED_DIR, max_age: max_age)
|
|
390
|
+
|
|
362
391
|
# @return [Boolean] whether the queued input +enqueued_id+ was removed
|
|
363
392
|
# before it ran (#take_queued_input's tombstone), or is being removed:
|
|
364
393
|
# the tombstone goes first, so while its claimed file is still there
|
|
@@ -1177,6 +1177,17 @@ module Samagotchi
|
|
|
1177
1177
|
def error = ack["error"]
|
|
1178
1178
|
|
|
1179
1179
|
def detail = ack["detail"]
|
|
1180
|
+
|
|
1181
|
+
# What the worker applied (a Bridge ack only): the delivery asked for,
|
|
1182
|
+
# or next_step for a --queue that found no turn running; nil from the
|
|
1183
|
+
# input file or a worker older than delivery.
|
|
1184
|
+
def delivery = ack["delivery"]
|
|
1185
|
+
|
|
1186
|
+
# A --cut's outcome ("now", "waits", "off"), nil when nothing was cut.
|
|
1187
|
+
def cut = ack["cut"]
|
|
1188
|
+
|
|
1189
|
+
# The steer cut-after seconds, with a cut that waits only.
|
|
1190
|
+
def cut_after = ack["cut_after"]
|
|
1180
1191
|
end
|
|
1181
1192
|
|
|
1182
1193
|
# Hand a user message to the session the way a UI does, for the web
|