samagotchi 0.95.0 → 0.97.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 +39 -1
- data/bin/chi +3 -0
- data/docs/architecture.md +2 -0
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +13 -1
- data/docs/configuration.md +13 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +3 -1
- data/docs/memory.md +43 -3
- data/docs/plugins.md +4 -3
- data/docs/sessions.md +8 -2
- data/lib/samagotchi/bundle_command/check.rb +5 -3
- data/lib/samagotchi/bundle_command/try/run.rb +19 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/engine.rb +49 -38
- data/lib/samagotchi/llm/errors.rb +100 -2
- data/lib/samagotchi/memory_bundle/profile.rb +14 -4
- data/lib/samagotchi/memory_command.rb +237 -0
- data/lib/samagotchi/plugin/api.rb +7 -3
- data/lib/samagotchi/plugin/sessions.rb +4 -3
- data/lib/samagotchi/send_command.rb +12 -12
- data/lib/samagotchi/session.rb +13 -1
- data/lib/samagotchi/session_commands.rb +13 -0
- data/lib/samagotchi/session_continue.rb +8 -8
- data/lib/samagotchi/session_delete_command.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +29 -0
- data/lib/samagotchi/session_manager.rb +24 -7
- data/lib/samagotchi/sessions_command.rb +8 -6
- 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/title_command.rb +61 -0
- 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 +4 -4
- data/lib/samagotchi/web/public/app.js +12 -0
- data/lib/samagotchi/web/public/chains.js +21 -8
- data/lib/samagotchi/web/public/format.js +4 -3
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/info_bar_model.js +2 -0
- data/lib/samagotchi/web/public/session_rename.js +86 -0
- data/lib/samagotchi/web/public/session_state.js +4 -2
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +24 -7
- data/lib/samagotchi/worker_idle_exit.rb +16 -8
- metadata +4 -1
|
@@ -384,6 +384,24 @@ module Samagotchi
|
|
|
384
384
|
# Seconds to wait before retrying a CreditsHeld without a Retry-After.
|
|
385
385
|
CREDITS_SETTLE_DELAY = 20.0
|
|
386
386
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
387
|
+
# How much of a server's error message the summary keeps.
|
|
388
|
+
MESSAGE_LIMIT = 500
|
|
389
|
+
# The 404 model hint's bounds (the message is the server's): how much
|
|
390
|
+
# of the message it reads, the longest model id, how many listed ids.
|
|
391
|
+
HINT_SCAN_CHARS = 64 * 1024
|
|
392
|
+
HINT_ID_MAX = 200
|
|
393
|
+
HINT_IDS_MAX = 2000
|
|
394
|
+
# A typo the hint forgives: at most this many edits, fewer than a
|
|
395
|
+
# third of the name, in a name at least HINT_FUZZY_MIN long.
|
|
396
|
+
HINT_MAX_EDITS = 2
|
|
397
|
+
HINT_FUZZY_MIN = 6
|
|
398
|
+
# A 404's model it couldn't find: "Model 'X' not found", "model `X`
|
|
399
|
+
# does not exist", "model X-1 not found" (unquoted, an id-like word,
|
|
400
|
+
# checked after the match). Bounded quantifiers: a server's long token
|
|
401
|
+
# can't make the match quadratic (Ruby 3.3 has no regex memoization).
|
|
402
|
+
MODEL_NOT_FOUND_RE = /model\s+(?:["'`]([^"'`\s]{1,#{HINT_ID_MAX}})["'`]|([^"'`\s]{1,#{HINT_ID_MAX}}))\s+(?:was\s+)?(?:not found|does not exist)/i
|
|
403
|
+
# The ids a 404 lists after "Available models:".
|
|
404
|
+
AVAILABLE_MODELS_RE = /available models?:\s*(.+)\z/im
|
|
387
405
|
# The HTTP status an Anthropic error.type stands for, for an error
|
|
388
406
|
# object that carries no numeric code (a Messages stream's error event,
|
|
389
407
|
# an OpenAI error whose code is null or a name).
|
|
@@ -426,6 +444,7 @@ module Samagotchi
|
|
|
426
444
|
else
|
|
427
445
|
OutOfCredits.new(text, **options)
|
|
428
446
|
end
|
|
447
|
+
when 404 then bad_request(text, status, message, options.merge(hint: model_hint(error_message(body, limit: nil))))
|
|
429
448
|
when 400..499 then bad_request(text, status, message, options)
|
|
430
449
|
when 500..599
|
|
431
450
|
ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
|
|
@@ -492,7 +511,7 @@ module Samagotchi
|
|
|
492
511
|
# upstream failure (OpenRouter) often says only "Provider returned
|
|
493
512
|
# error" and puts the upstream's words in error.metadata.raw and its
|
|
494
513
|
# name in metadata.provider_name; both are added when present.
|
|
495
|
-
def error_message(body)
|
|
514
|
+
def error_message(body, limit: MESSAGE_LIMIT)
|
|
496
515
|
parsed = parse_json(body.to_s)
|
|
497
516
|
message =
|
|
498
517
|
if parsed.is_a?(Hash)
|
|
@@ -502,9 +521,88 @@ module Samagotchi
|
|
|
502
521
|
message = body.to_s if message.nil? || message.to_s.strip.empty?
|
|
503
522
|
message = message.to_s.strip
|
|
504
523
|
message = with_upstream(message, parsed["error"]["metadata"]) if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
|
|
505
|
-
|
|
524
|
+
return "(empty body)" if message.empty?
|
|
525
|
+
|
|
526
|
+
limit ? message[0, limit] : message
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# "did you mean 'ID'?" for a 404 that names the model it couldn't find
|
|
530
|
+
# and lists the ones it has (oMLX: "Model 'X' not found. Available
|
|
531
|
+
# models: a, b, …"): the listed id that is X but for case, the one
|
|
532
|
+
# whose last segment (after "/" or "--") is X, else the one id a
|
|
533
|
+
# typo away (#typo_distance, whole or its last segment). Read from
|
|
534
|
+
# the message past the summary's cut (MESSAGE_LIMIT), within the
|
|
535
|
+
# HINT_* bounds; nil when there is no list, nothing close or a tie.
|
|
536
|
+
def model_hint(message)
|
|
537
|
+
message = message[0, HINT_SCAN_CHARS]
|
|
538
|
+
match = MODEL_NOT_FOUND_RE.match(message)
|
|
539
|
+
requested = match && (match[1] || match[2])
|
|
540
|
+
return nil unless requested && (match[1] || requested.match?(%r{[-/.:_\d]}))
|
|
541
|
+
|
|
542
|
+
list = message[AVAILABLE_MODELS_RE, 1]
|
|
543
|
+
return nil unless list
|
|
544
|
+
|
|
545
|
+
ids = list.split(/,\s*|\s+/, HINT_IDS_MAX + 1).first(HINT_IDS_MAX)
|
|
546
|
+
.map { |id| id.delete("[]\"'`").sub(/[.;]+\z/, "") }
|
|
547
|
+
.reject { |id| id.empty? || id.length > HINT_ID_MAX }.uniq
|
|
548
|
+
return nil if ids.empty? || ids.include?(requested)
|
|
549
|
+
|
|
550
|
+
close = closest_model_id(requested, ids)
|
|
551
|
+
close && "did you mean '#{close}'?"
|
|
506
552
|
end
|
|
507
553
|
|
|
554
|
+
# The id that is +requested+ but for case, or whose last segment is,
|
|
555
|
+
# else the one id a typo away (#typo_distance); nil on a tie.
|
|
556
|
+
def closest_model_id(requested, ids)
|
|
557
|
+
probe = requested.downcase
|
|
558
|
+
exact = ids.find { |id| id.downcase == probe } || ids.find { |id| last_segment(id) == probe }
|
|
559
|
+
return exact if exact
|
|
560
|
+
|
|
561
|
+
scored = ids.filter_map do |id|
|
|
562
|
+
distance = [id.downcase, last_segment(id)].uniq.filter_map { |name| typo_distance(probe, name) }.min
|
|
563
|
+
[distance, id] if distance
|
|
564
|
+
end
|
|
565
|
+
return nil if scored.empty?
|
|
566
|
+
|
|
567
|
+
best = scored.map(&:first).min
|
|
568
|
+
closest = scored.select { |distance, _| distance == best }
|
|
569
|
+
closest.size == 1 ? closest.first.last : nil
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# The edit distance of +a+ and +b+ when it is a typo's: at most
|
|
573
|
+
# HINT_MAX_EDITS and under a third of the shorter one, which has
|
|
574
|
+
# HINT_FUZZY_MIN characters or more; else nil. A banded Levenshtein:
|
|
575
|
+
# linear in the length.
|
|
576
|
+
def typo_distance(a, b)
|
|
577
|
+
shorter = [a.length, b.length].min
|
|
578
|
+
return nil if shorter < HINT_FUZZY_MIN || (a.length - b.length).abs > HINT_MAX_EDITS
|
|
579
|
+
|
|
580
|
+
distance = banded_distance(a, b, HINT_MAX_EDITS)
|
|
581
|
+
distance if distance && distance * 3 < shorter
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Levenshtein distance of +a+ and +b+ if at most +limit+, else nil;
|
|
585
|
+
# only cells within +limit+ of the diagonal are computed.
|
|
586
|
+
def banded_distance(a, b, limit)
|
|
587
|
+
far = limit + 1
|
|
588
|
+
prev = Hash.new(far)
|
|
589
|
+
(0..[limit, b.length].min).each { |j| prev[j] = j }
|
|
590
|
+
a.each_char.with_index(1) do |ca, i|
|
|
591
|
+
cur = Hash.new(far)
|
|
592
|
+
cur[0] = i if i <= limit
|
|
593
|
+
([i - limit, 1].max..[i + limit, b.length].min).each do |j|
|
|
594
|
+
cost = ca == b[j - 1] ? 0 : 1
|
|
595
|
+
cur[j] = [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost, far].min
|
|
596
|
+
end
|
|
597
|
+
return nil if cur.empty? || cur.values.min > limit
|
|
598
|
+
|
|
599
|
+
prev = cur
|
|
600
|
+
end
|
|
601
|
+
prev[b.length] <= limit ? prev[b.length] : nil
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def last_segment(id) = id.downcase.split(%r{/|--}).last
|
|
605
|
+
|
|
508
606
|
# error.metadata.reason of a JSON error body (OpenRouter), or nil.
|
|
509
607
|
def error_reason(body)
|
|
510
608
|
parsed = parse_json(body.to_s)
|
|
@@ -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
|
|
@@ -21,8 +21,12 @@ 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.
|
|
26
30
|
SINCE = {
|
|
27
31
|
"ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
|
|
28
32
|
"Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
|
|
@@ -31,7 +35,7 @@ module Samagotchi
|
|
|
31
35
|
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
32
36
|
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
33
37
|
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
34
|
-
"ctx.cost" => "0.83.0"
|
|
38
|
+
"ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
|
|
35
39
|
}.freeze
|
|
36
40
|
|
|
37
41
|
# @param bundle [String]
|
|
@@ -53,7 +53,7 @@ module Samagotchi
|
|
|
53
53
|
|
|
54
54
|
child = SessionManager.spawn_session(
|
|
55
55
|
prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
|
|
56
|
-
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) },
|
|
56
|
+
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, preview: title,
|
|
57
57
|
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
|
|
58
58
|
setup: @host.setup&.call || SessionSetup.new
|
|
59
59
|
)
|
|
@@ -105,7 +105,8 @@ module Samagotchi
|
|
|
105
105
|
# Session +id+ now: from its worker when one runs (with a running turn
|
|
106
106
|
# so far), else as saved.
|
|
107
107
|
# @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
|
|
108
|
-
# (
|
|
108
|
+
# (title: its own name, /title, else its first message's preview;
|
|
109
|
+
# messages without the system prompt, symbol keys)
|
|
109
110
|
# @raise [Error] no such session
|
|
110
111
|
def read(id)
|
|
111
112
|
state_dir = self.state_dir
|
|
@@ -120,7 +121,7 @@ module Samagotchi
|
|
|
120
121
|
messages += Bridge::TurnAccumulator.messages_of(deep_symbolize(turn))
|
|
121
122
|
end
|
|
122
123
|
end
|
|
123
|
-
{ id: sid, title: session.
|
|
124
|
+
{ id: sid, title: session.display_title, status: session.status.to_s, parent_id: session.parent_id,
|
|
124
125
|
running: running, messages: without_system_head(messages) }
|
|
125
126
|
end
|
|
126
127
|
|
|
@@ -416,7 +416,7 @@ module Samagotchi
|
|
|
416
416
|
# says whether it ran as one.
|
|
417
417
|
through_worker = images || command_like?(prompt)
|
|
418
418
|
begin
|
|
419
|
-
start = through_worker ? { prompt: nil,
|
|
419
|
+
start = through_worker ? { prompt: nil, preview: prompt } : { prompt: prompt }
|
|
420
420
|
session = if options[:continues]
|
|
421
421
|
SessionManager.continue_session(options[:continues], **start, model: options[:model],
|
|
422
422
|
state_dir: @state_dir)
|
|
@@ -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)"
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -56,6 +56,10 @@ module Samagotchi
|
|
|
56
56
|
# from it (--mute): the worker rebuilds the same prompt on a respawn.
|
|
57
57
|
# Names as given; the engine normalizes them.
|
|
58
58
|
attr_accessor :preloaded_memory_names, :muted_memory_names
|
|
59
|
+
# The name the user gave the session (/title, TitleCommand), else nil:
|
|
60
|
+
# every list, card and tab shows it in place of #first_preview (see
|
|
61
|
+
# #display_title). A continue's new link inherits it.
|
|
62
|
+
attr_accessor :title
|
|
59
63
|
# The files chi --append-system-prompt put into the session's system
|
|
60
64
|
# prompt (absolute paths; config.yml's append_system_prompt: entries
|
|
61
65
|
# aren't stored): kept on respawns, copied by a fork and a continue
|
|
@@ -276,7 +280,7 @@ module Samagotchi
|
|
|
276
280
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
277
281
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
278
282
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
279
|
-
toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil)
|
|
283
|
+
toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil, title: nil)
|
|
280
284
|
@id = id
|
|
281
285
|
@metadata_version = metadata_version
|
|
282
286
|
@mode = mode
|
|
@@ -289,6 +293,7 @@ module Samagotchi
|
|
|
289
293
|
@status = status
|
|
290
294
|
@last_prompt = last_prompt
|
|
291
295
|
@first_preview = first_preview
|
|
296
|
+
@title = title
|
|
292
297
|
@test_run = !!test_run
|
|
293
298
|
self.pending_question = pending_question
|
|
294
299
|
@used_memory_names = self.class.name_list(used_memory_names)
|
|
@@ -455,6 +460,7 @@ module Samagotchi
|
|
|
455
460
|
"status" => STATUS_IDLE,
|
|
456
461
|
"last_prompt" => "",
|
|
457
462
|
"first_preview" => "",
|
|
463
|
+
"title" => nil,
|
|
458
464
|
"test_run" => false,
|
|
459
465
|
"pending_question" => nil,
|
|
460
466
|
"used_memory_names" => [],
|
|
@@ -719,6 +725,12 @@ module Samagotchi
|
|
|
719
725
|
File.join(Paths.state_home(env: env), STATE_SUBDIR)
|
|
720
726
|
end
|
|
721
727
|
|
|
728
|
+
# What names the session: its #title when it has one, else its
|
|
729
|
+
# #first_preview.
|
|
730
|
+
def display_title
|
|
731
|
+
title.to_s.strip.empty? ? first_preview.to_s : title
|
|
732
|
+
end
|
|
733
|
+
|
|
722
734
|
# Derive and cache the first user-message preview in the session record.
|
|
723
735
|
# Returns true if the cached value was set or updated.
|
|
724
736
|
# A session with only context notes so far previews by its first note
|
|
@@ -14,6 +14,7 @@ require_relative "llm_context_command"
|
|
|
14
14
|
require_relative "thinking_command"
|
|
15
15
|
require_relative "toolkit_command"
|
|
16
16
|
require_relative "parent_answers_command"
|
|
17
|
+
require_relative "title_command"
|
|
17
18
|
require_relative "reasoning_history_command"
|
|
18
19
|
require_relative "guardrails/parent_policy"
|
|
19
20
|
|
|
@@ -37,6 +38,7 @@ module Samagotchi
|
|
|
37
38
|
THINKING_COMMAND = ThinkingCommand::NAME
|
|
38
39
|
TOOLKIT_COMMAND = ToolkitCommand::NAME
|
|
39
40
|
PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
|
|
41
|
+
TITLE_COMMAND = TitleCommand::NAME
|
|
40
42
|
REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
|
|
41
43
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
42
44
|
# shows this many per host; /models <text> lists every match.
|
|
@@ -112,6 +114,9 @@ module Samagotchi
|
|
|
112
114
|
"never looser than config.yml's)",
|
|
113
115
|
id: :parent_answers, mid_turn: show_mid_turn(PARENT_ANSWERS_COMMAND),
|
|
114
116
|
match: ->(text) { text.match?(%r{\A/parent-answers(?:\s+.*)?\z}) }) { |text| parent_answers(text) }
|
|
117
|
+
# Only a name: renaming a running session doesn't wait for its turn.
|
|
118
|
+
registry.register(TITLE_COMMAND, "show or set this session's title (--clear: back to its first message)",
|
|
119
|
+
id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
|
|
115
120
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
116
121
|
registry.register("/stats", "show the session's stats", local: true)
|
|
117
122
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -522,6 +527,14 @@ module Samagotchi
|
|
|
522
527
|
reply(output, status: status, changed: changed ? [:parent_answers] : [])
|
|
523
528
|
end
|
|
524
529
|
|
|
530
|
+
# /title (TitleCommand); :title in changed when it named the session or
|
|
531
|
+
# cleared its name. A plain save: no setting moved, and it may run on
|
|
532
|
+
# an anytime thread beside a turn.
|
|
533
|
+
def title(text)
|
|
534
|
+
output, changed, status = TitleCommand.new(engine: @engine, save: @save).run(text.delete_prefix(TITLE_COMMAND))
|
|
535
|
+
reply(output, status: status, changed: changed ? [:title] : [])
|
|
536
|
+
end
|
|
537
|
+
|
|
525
538
|
# A session setting changed (/llm-context, /thinking, /reasoning-history):
|
|
526
539
|
# saved with its model.
|
|
527
540
|
def save_setup(session)
|