personality 0.1.0 → 0.1.1
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/exe/psn-tts +7 -0
- data/lib/personality/cart_manager.rb +292 -0
- data/lib/personality/cli/cart.rb +167 -0
- data/lib/personality/cli/memory.rb +1 -1
- data/lib/personality/indexer.rb +8 -4
- data/lib/personality/init.rb +1 -1
- data/lib/personality/mcp/server.rb +152 -1
- data/lib/personality/mcp/tts_server.rb +126 -0
- data/lib/personality/memory.rb +6 -6
- data/lib/personality/persona_builder.rb +193 -0
- data/lib/personality/training_parser.rb +167 -0
- data/lib/personality/tts.rb +1 -1
- data/lib/personality/version.rb +1 -1
- data/lib/personality.rb +3 -0
- metadata +36 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d10c8c7ae61bc430f61154cdc2760ece25c3868a70b3eafd1405830679ca348
|
|
4
|
+
data.tar.gz: 788487ea2930b59b020c8aee337ad8b1eea4448ff43ded86d23c4c82616871c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6c572c470a06ef00997edc63a2987aecc40864d480ccad7577b431659634d43e365bda551906a2c43c759491757e751735e5e3baedfd874c78fad592eb9a446
|
|
7
|
+
data.tar.gz: 35949f8a0ebaea35733f45b66fb6f2eb90ea6a9c74bbf8da3dd101ddaa3f633b314a900d47be30b63f2c8b29a02d9306775930238c781c8750d5ea2ffb757a87
|
data/exe/psn-tts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip" unless defined?(Zip)
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Personality
|
|
8
|
+
# Identity configuration from preferences
|
|
9
|
+
IdentityConfig = Struct.new(:agent, :name, :full_name, :version, :type, :source, :tagline, keyword_init: true) do
|
|
10
|
+
def self.from_hash(h)
|
|
11
|
+
h ||= {}
|
|
12
|
+
new(
|
|
13
|
+
agent: h["agent"].to_s,
|
|
14
|
+
name: h["name"].to_s,
|
|
15
|
+
full_name: h["full_name"].to_s,
|
|
16
|
+
version: h["version"].to_s,
|
|
17
|
+
type: h["type"].to_s,
|
|
18
|
+
source: h["source"].to_s,
|
|
19
|
+
tagline: h["tagline"].to_s
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_hash
|
|
24
|
+
members.each_with_object({}) { |k, h|
|
|
25
|
+
v = self[k]
|
|
26
|
+
h[k.to_s] = v unless v.nil? || v.empty?
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# TTS configuration
|
|
32
|
+
TTSConfig = Struct.new(:enabled, :voice, keyword_init: true) do
|
|
33
|
+
def self.from_hash(h)
|
|
34
|
+
h ||= {}
|
|
35
|
+
new(enabled: h.fetch("enabled", true), voice: h.fetch("voice", "").to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_hash
|
|
39
|
+
{"enabled" => enabled, "voice" => voice}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Preferences config (identity + tts + extras)
|
|
44
|
+
PreferencesConfig = Struct.new(:identity, :tts, :extra, keyword_init: true) do
|
|
45
|
+
def self.from_hash(data)
|
|
46
|
+
data ||= {}
|
|
47
|
+
known = %w[identity tts]
|
|
48
|
+
extra = data.except(*known)
|
|
49
|
+
new(
|
|
50
|
+
identity: IdentityConfig.from_hash(data["identity"]),
|
|
51
|
+
tts: TTSConfig.from_hash(data["tts"]),
|
|
52
|
+
extra: extra
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_hash
|
|
57
|
+
h = {}
|
|
58
|
+
id_h = identity.to_hash
|
|
59
|
+
h["identity"] = id_h unless id_h.empty?
|
|
60
|
+
h["tts"] = tts.to_hash if tts.enabled || !tts.voice.empty?
|
|
61
|
+
h.merge(extra || {})
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A loaded cartridge
|
|
66
|
+
Cartridge = Struct.new(:path, :tag, :version, :memories, :preferences, :created_at, keyword_init: true) do
|
|
67
|
+
def name
|
|
68
|
+
n = preferences&.identity&.name
|
|
69
|
+
(n.nil? || n.empty?) ? tag : n
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def voice
|
|
73
|
+
preferences&.tts&.voice
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def memory_count
|
|
77
|
+
memories&.size || 0
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Manages .pcart (personality cartridge) ZIP files.
|
|
82
|
+
#
|
|
83
|
+
# A .pcart file is a ZIP archive containing:
|
|
84
|
+
# persona.yml - tag, version, and memories array
|
|
85
|
+
# preferences.yml - identity metadata and TTS settings
|
|
86
|
+
#
|
|
87
|
+
class CartManager
|
|
88
|
+
EXTENSION = ".pcart"
|
|
89
|
+
|
|
90
|
+
def initialize(carts_dir: nil, training_dir: nil)
|
|
91
|
+
@carts_dir = carts_dir || File.join(Dir.home, ".local", "share", "personality", "carts")
|
|
92
|
+
@training_dir = training_dir
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
attr_reader :carts_dir, :training_dir
|
|
96
|
+
|
|
97
|
+
# Load a cartridge from a .pcart file.
|
|
98
|
+
#
|
|
99
|
+
# @param path [String] Path to the .pcart file
|
|
100
|
+
# @return [Cartridge]
|
|
101
|
+
def load_cart(path)
|
|
102
|
+
raise Errno::ENOENT, "Cart file not found: #{path}" unless File.exist?(path)
|
|
103
|
+
|
|
104
|
+
require "zip"
|
|
105
|
+
Zip::File.open(path) do |zf|
|
|
106
|
+
# persona.yml is required
|
|
107
|
+
persona_entry = zf.find_entry("persona.yml")
|
|
108
|
+
raise ArgumentError, "Missing persona.yml in cart" unless persona_entry
|
|
109
|
+
|
|
110
|
+
persona_data = YAML.safe_load(persona_entry.get_input_stream.read, permitted_classes: [Date, Time]) || {}
|
|
111
|
+
|
|
112
|
+
tag = persona_data.fetch("tag", File.basename(path, EXTENSION))
|
|
113
|
+
version = persona_data.fetch("version", "").to_s
|
|
114
|
+
|
|
115
|
+
memories = parse_memories(persona_data.fetch("memories", []))
|
|
116
|
+
|
|
117
|
+
# preferences.yml is optional
|
|
118
|
+
prefs_data = {}
|
|
119
|
+
prefs_entry = zf.find_entry("preferences.yml")
|
|
120
|
+
if prefs_entry
|
|
121
|
+
prefs_data = YAML.safe_load(prefs_entry.get_input_stream.read, permitted_classes: [Date, Time]) || {}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Also check persona.yml for embedded preferences (training format)
|
|
125
|
+
if persona_data.key?("preferences")
|
|
126
|
+
base_prefs = persona_data["preferences"]
|
|
127
|
+
base_prefs.each { |k, v| prefs_data[k] = v unless prefs_data.key?(k) } if base_prefs.is_a?(Hash)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
preferences = PreferencesConfig.from_hash(prefs_data)
|
|
131
|
+
|
|
132
|
+
Cartridge.new(
|
|
133
|
+
path: path,
|
|
134
|
+
tag: tag,
|
|
135
|
+
version: version,
|
|
136
|
+
memories: memories,
|
|
137
|
+
preferences: preferences
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Save a cartridge to a .pcart file.
|
|
143
|
+
#
|
|
144
|
+
# @param cart [Cartridge] The cartridge to save
|
|
145
|
+
# @param path [String, nil] Output path (defaults to carts_dir/tag.pcart)
|
|
146
|
+
# @return [String] Path to the saved file
|
|
147
|
+
def save_cart(cart, path: nil)
|
|
148
|
+
path ||= begin
|
|
149
|
+
FileUtils.mkdir_p(@carts_dir)
|
|
150
|
+
File.join(@carts_dir, "#{cart.tag}#{EXTENSION}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
154
|
+
|
|
155
|
+
persona_yaml = YAML.dump({
|
|
156
|
+
"tag" => cart.tag,
|
|
157
|
+
"version" => cart.version,
|
|
158
|
+
"memories" => cart.memories.map { |m| {"subject" => m.subject, "content" => m.content} }
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
prefs_yaml = YAML.dump(cart.preferences.to_hash)
|
|
162
|
+
|
|
163
|
+
require "zip"
|
|
164
|
+
Zip::OutputStream.open(path) do |zos|
|
|
165
|
+
zos.put_next_entry("persona.yml")
|
|
166
|
+
zos.write(persona_yaml)
|
|
167
|
+
zos.put_next_entry("preferences.yml")
|
|
168
|
+
zos.write(prefs_yaml)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
path
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Create a cartridge from a training YAML file.
|
|
175
|
+
#
|
|
176
|
+
# @param training_path [String] Path to the training file
|
|
177
|
+
# @param output_path [String, nil] Output path for the .pcart file
|
|
178
|
+
# @return [Cartridge]
|
|
179
|
+
def create_from_training(training_path, output_path: nil)
|
|
180
|
+
require_relative "training_parser"
|
|
181
|
+
|
|
182
|
+
parser = TrainingParser.new
|
|
183
|
+
doc = parser.parse_file(training_path)
|
|
184
|
+
|
|
185
|
+
tag = doc.tag.empty? ? File.basename(training_path, ".*").downcase : doc.tag
|
|
186
|
+
preferences = PreferencesConfig.from_hash(doc.preferences)
|
|
187
|
+
|
|
188
|
+
cart = Cartridge.new(
|
|
189
|
+
path: nil,
|
|
190
|
+
tag: tag,
|
|
191
|
+
version: doc.version,
|
|
192
|
+
memories: doc.memories,
|
|
193
|
+
preferences: preferences,
|
|
194
|
+
created_at: Time.now.utc.to_s
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
saved_path = save_cart(cart, path: output_path)
|
|
198
|
+
cart.path = saved_path
|
|
199
|
+
cart
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Import a cart's memories into the database.
|
|
203
|
+
#
|
|
204
|
+
# @param cart [Cartridge] The cartridge to import
|
|
205
|
+
# @return [Hash] Import result with counts
|
|
206
|
+
def import_memories(cart)
|
|
207
|
+
require_relative "db"
|
|
208
|
+
require_relative "cart"
|
|
209
|
+
require_relative "memory"
|
|
210
|
+
|
|
211
|
+
DB.migrate!
|
|
212
|
+
|
|
213
|
+
# Ensure cart exists in DB with full identity
|
|
214
|
+
db_cart = Cart.create(
|
|
215
|
+
cart.tag,
|
|
216
|
+
name: cart.preferences.identity.name,
|
|
217
|
+
type: cart.preferences.identity.type,
|
|
218
|
+
tagline: cart.preferences.identity.tagline
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Update fields that create doesn't set
|
|
222
|
+
db = DB.connection
|
|
223
|
+
db.execute(
|
|
224
|
+
"UPDATE carts SET version = ?, source = ?, name = COALESCE(NULLIF(?, ''), name), type = COALESCE(NULLIF(?, ''), type), tagline = COALESCE(NULLIF(?, ''), tagline) WHERE id = ?",
|
|
225
|
+
[cart.version, cart.preferences.identity.source, cart.preferences.identity.name, cart.preferences.identity.type, cart.preferences.identity.tagline, db_cart[:id]]
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
mem = Memory.new
|
|
229
|
+
stored = 0
|
|
230
|
+
skipped = 0
|
|
231
|
+
|
|
232
|
+
cart.memories.each do |training_mem|
|
|
233
|
+
# Check for existing memory with same subject
|
|
234
|
+
existing = db.execute(
|
|
235
|
+
"SELECT id FROM memories WHERE cart_id = ? AND subject = ?",
|
|
236
|
+
[db_cart[:id], training_mem.subject]
|
|
237
|
+
).first
|
|
238
|
+
|
|
239
|
+
if existing
|
|
240
|
+
skipped += 1
|
|
241
|
+
else
|
|
242
|
+
mem.store(subject: training_mem.subject, content: training_mem.content)
|
|
243
|
+
stored += 1
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
{stored: stored, skipped: skipped, total: cart.memory_count, cart_id: db_cart[:id]}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# List available .pcart files.
|
|
251
|
+
#
|
|
252
|
+
# @return [Array<String>] Sorted list of cart file paths
|
|
253
|
+
def list_carts
|
|
254
|
+
return [] unless Dir.exist?(@carts_dir)
|
|
255
|
+
Dir.glob(File.join(@carts_dir, "*#{EXTENSION}")).sort
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get quick info about a cart without fully loading it.
|
|
259
|
+
#
|
|
260
|
+
# @param path [String] Path to the cart file
|
|
261
|
+
# @return [Hash]
|
|
262
|
+
def cart_info(path)
|
|
263
|
+
require "zip"
|
|
264
|
+
Zip::File.open(path) do |zf|
|
|
265
|
+
entry = zf.find_entry("persona.yml")
|
|
266
|
+
return {error: "Missing persona.yml"} unless entry
|
|
267
|
+
|
|
268
|
+
data = YAML.safe_load(entry.get_input_stream.read, permitted_classes: [Date, Time]) || {}
|
|
269
|
+
{
|
|
270
|
+
tag: data.fetch("tag", File.basename(path, EXTENSION)),
|
|
271
|
+
version: data.fetch("version", "").to_s,
|
|
272
|
+
memory_count: Array(data.fetch("memories", [])).size
|
|
273
|
+
}
|
|
274
|
+
end
|
|
275
|
+
rescue => e
|
|
276
|
+
{error: e.message}
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def parse_memories(list)
|
|
282
|
+
return [] unless list.is_a?(Array)
|
|
283
|
+
|
|
284
|
+
list.filter_map do |item|
|
|
285
|
+
next unless item.is_a?(Hash) && item["subject"] && item["content"]
|
|
286
|
+
content = item["content"]
|
|
287
|
+
content = content.map(&:to_s).join(", ") if content.is_a?(Array)
|
|
288
|
+
TrainingMemory.new(subject: item["subject"].to_s, content: content.to_s)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
data/lib/personality/cli/cart.rb
CHANGED
|
@@ -53,6 +53,173 @@ module Personality
|
|
|
53
53
|
puts "#{Pastel.new.green("Created:")} #{cart[:tag]} (id: #{cart[:id]})"
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
desc "teach FILE", "Learn a persona from a training YAML file"
|
|
57
|
+
option :output, type: :string, aliases: "-o", desc: "Output .pcart path"
|
|
58
|
+
option :import, type: :boolean, default: true, desc: "Import memories into database"
|
|
59
|
+
def teach(file)
|
|
60
|
+
require_relative "../cart_manager"
|
|
61
|
+
require_relative "../db"
|
|
62
|
+
require "pastel"
|
|
63
|
+
require "tty-spinner"
|
|
64
|
+
|
|
65
|
+
pastel = Pastel.new
|
|
66
|
+
manager = CartManager.new
|
|
67
|
+
|
|
68
|
+
# Parse and create .pcart
|
|
69
|
+
spinner = TTY::Spinner.new(" :spinner Parsing training file...", format: :dots)
|
|
70
|
+
spinner.auto_spin
|
|
71
|
+
cart = manager.create_from_training(file, output_path: options[:output])
|
|
72
|
+
spinner.success(pastel.green("#{cart.name} — #{cart.memory_count} memories"))
|
|
73
|
+
|
|
74
|
+
puts " #{pastel.dim("Tag:")} #{cart.tag}"
|
|
75
|
+
puts " #{pastel.dim("Version:")} #{cart.version}" unless cart.version.to_s.empty?
|
|
76
|
+
puts " #{pastel.dim("Voice:")} #{cart.voice}" if cart.voice && !cart.voice.empty?
|
|
77
|
+
puts " #{pastel.dim("Cart:")} #{cart.path}"
|
|
78
|
+
|
|
79
|
+
# Import memories into DB
|
|
80
|
+
if options[:import]
|
|
81
|
+
spinner = TTY::Spinner.new(" :spinner Importing memories...", format: :dots)
|
|
82
|
+
spinner.auto_spin
|
|
83
|
+
result = manager.import_memories(cart)
|
|
84
|
+
spinner.success(pastel.green("#{result[:stored]} stored, #{result[:skipped]} skipped"))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Show persona instructions preview
|
|
88
|
+
require_relative "../persona_builder"
|
|
89
|
+
builder = PersonaBuilder.new
|
|
90
|
+
summary = builder.build_summary(cart)
|
|
91
|
+
puts "\n #{pastel.bold(summary)}"
|
|
92
|
+
puts " #{pastel.dim("Run `psn cart show #{cart.tag}` for full instructions")}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
desc "teach-all DIR", "Learn all personas from a training directory"
|
|
96
|
+
option :force, type: :boolean, default: false, aliases: "-f", desc: "Overwrite existing carts"
|
|
97
|
+
def teach_all(dir)
|
|
98
|
+
require_relative "../cart_manager"
|
|
99
|
+
require_relative "../training_parser"
|
|
100
|
+
require_relative "../db"
|
|
101
|
+
require "pastel"
|
|
102
|
+
|
|
103
|
+
pastel = Pastel.new
|
|
104
|
+
parser = TrainingParser.new
|
|
105
|
+
manager = CartManager.new
|
|
106
|
+
|
|
107
|
+
files = parser.list_files(dir)
|
|
108
|
+
if files.empty?
|
|
109
|
+
puts pastel.dim("No training files found in #{dir}")
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
puts pastel.bold("Found #{files.size} training files\n")
|
|
114
|
+
|
|
115
|
+
files.each do |file|
|
|
116
|
+
tag = File.basename(file, ".*").downcase
|
|
117
|
+
cart_path = File.join(manager.carts_dir, "#{tag}.pcart")
|
|
118
|
+
|
|
119
|
+
if File.exist?(cart_path) && !options[:force]
|
|
120
|
+
puts " #{pastel.yellow("skip")} #{tag} (already exists, use --force)"
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
cart = manager.create_from_training(file)
|
|
126
|
+
result = manager.import_memories(cart)
|
|
127
|
+
puts " #{pastel.green("✓")} #{cart.name} — #{cart.memory_count} memories (#{result[:stored]} new)"
|
|
128
|
+
rescue => e
|
|
129
|
+
puts " #{pastel.red("✗")} #{File.basename(file)} — #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
desc "show [TAG]", "Show persona details and instructions"
|
|
135
|
+
option :memories, type: :boolean, default: false, aliases: "-m", desc: "Show raw memories"
|
|
136
|
+
option :instructions, type: :boolean, default: false, aliases: "-i", desc: "Show full LLM instructions"
|
|
137
|
+
def show(tag = nil)
|
|
138
|
+
require_relative "../cart_manager"
|
|
139
|
+
require_relative "../persona_builder"
|
|
140
|
+
require "pastel"
|
|
141
|
+
|
|
142
|
+
pastel = Pastel.new
|
|
143
|
+
manager = CartManager.new
|
|
144
|
+
|
|
145
|
+
# Find the cart
|
|
146
|
+
if tag
|
|
147
|
+
path = File.join(manager.carts_dir, "#{tag.downcase}.pcart")
|
|
148
|
+
unless File.exist?(path)
|
|
149
|
+
puts pastel.red("Cart not found: #{tag}")
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
else
|
|
153
|
+
carts = manager.list_carts
|
|
154
|
+
if carts.empty?
|
|
155
|
+
puts pastel.dim("No carts found. Run `psn cart teach <file>` first.")
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
path = carts.first
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
cart = manager.load_cart(path)
|
|
162
|
+
builder = PersonaBuilder.new
|
|
163
|
+
identity = cart.preferences&.identity
|
|
164
|
+
|
|
165
|
+
puts pastel.bold(builder.build_summary(cart))
|
|
166
|
+
puts ""
|
|
167
|
+
puts " #{pastel.dim("Tag:")} #{cart.tag}"
|
|
168
|
+
puts " #{pastel.dim("Version:")} #{cart.version}" unless cart.version.to_s.empty?
|
|
169
|
+
puts " #{pastel.dim("Name:")} #{identity.full_name}" if identity && !identity.full_name.empty?
|
|
170
|
+
puts " #{pastel.dim("Type:")} #{identity.type}" if identity && !identity.type.empty?
|
|
171
|
+
puts " #{pastel.dim("Source:")} #{identity.source}" if identity && !identity.source.empty?
|
|
172
|
+
puts " #{pastel.dim("Tagline:")} #{identity.tagline}" if identity && !identity.tagline.empty?
|
|
173
|
+
puts " #{pastel.dim("Voice:")} #{cart.voice}" if cart.voice && !cart.voice.empty?
|
|
174
|
+
puts " #{pastel.dim("Memories:")} #{cart.memory_count}"
|
|
175
|
+
puts " #{pastel.dim("Cart:")} #{cart.path}"
|
|
176
|
+
|
|
177
|
+
if options[:memories]
|
|
178
|
+
puts "\n#{pastel.bold("Memories:")}\n\n"
|
|
179
|
+
cart.memories.each do |m|
|
|
180
|
+
puts " #{pastel.cyan(m.subject)}"
|
|
181
|
+
puts " #{m.content}\n\n"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if options[:instructions]
|
|
186
|
+
puts "\n#{pastel.bold("LLM Instructions:")}\n\n"
|
|
187
|
+
puts builder.build_instructions(cart)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
desc "carts", "List available .pcart files"
|
|
192
|
+
def carts
|
|
193
|
+
require_relative "../cart_manager"
|
|
194
|
+
require "pastel"
|
|
195
|
+
require "tty-table"
|
|
196
|
+
|
|
197
|
+
pastel = Pastel.new
|
|
198
|
+
manager = CartManager.new
|
|
199
|
+
|
|
200
|
+
carts = manager.list_carts
|
|
201
|
+
if carts.empty?
|
|
202
|
+
puts pastel.dim("No .pcart files found. Run `psn cart teach <file>` first.")
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
rows = carts.map do |path|
|
|
207
|
+
info = manager.cart_info(path)
|
|
208
|
+
[
|
|
209
|
+
info[:tag] || File.basename(path, ".pcart"),
|
|
210
|
+
info[:version] || "-",
|
|
211
|
+
info[:memory_count]&.to_s || "?",
|
|
212
|
+
(File.size(path) > 1024) ? "#{(File.size(path) / 1024.0).round(1)} KB" : "#{File.size(path)} B"
|
|
213
|
+
]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
table = TTY::Table.new(
|
|
217
|
+
header: %w[Tag Version Memories Size],
|
|
218
|
+
rows: rows
|
|
219
|
+
)
|
|
220
|
+
puts table.render(:unicode, padding: [0, 1])
|
|
221
|
+
end
|
|
222
|
+
|
|
56
223
|
def self.exit_on_failure?
|
|
57
224
|
true
|
|
58
225
|
end
|
|
@@ -116,7 +116,7 @@ module Personality
|
|
|
116
116
|
return unless data
|
|
117
117
|
|
|
118
118
|
transcript_path = data["transcript_path"]
|
|
119
|
-
|
|
119
|
+
nil unless transcript_path && File.exist?(transcript_path)
|
|
120
120
|
|
|
121
121
|
# Extract learnings from transcript — placeholder for future implementation
|
|
122
122
|
# For now, this is a no-op hook endpoint
|
data/lib/personality/indexer.rb
CHANGED
|
@@ -13,7 +13,11 @@ module Personality
|
|
|
13
13
|
def index_code(path:, project: nil, extensions: nil)
|
|
14
14
|
dir = File.expand_path(path)
|
|
15
15
|
proj = project || File.basename(dir)
|
|
16
|
-
exts =
|
|
16
|
+
exts = if extensions
|
|
17
|
+
extensions.map { |e| e.start_with?(".") ? e : ".#{e}" }.to_set
|
|
18
|
+
else
|
|
19
|
+
CODE_EXTENSIONS
|
|
20
|
+
end
|
|
17
21
|
|
|
18
22
|
index_files(dir, proj, exts, table: "code_chunks", vec_table: "vec_code", language: true)
|
|
19
23
|
end
|
|
@@ -153,8 +157,8 @@ module Personality
|
|
|
153
157
|
end
|
|
154
158
|
|
|
155
159
|
def search_table(db, table, vec_table, embedding, project:, limit:, type:)
|
|
156
|
-
if project
|
|
157
|
-
|
|
160
|
+
rows = if project
|
|
161
|
+
db.execute(<<~SQL, [embedding.to_json, limit, project])
|
|
158
162
|
SELECT c.id, c.path, c.content, c.project, v.distance
|
|
159
163
|
FROM #{vec_table} v
|
|
160
164
|
INNER JOIN #{table} c ON c.id = v.chunk_id
|
|
@@ -163,7 +167,7 @@ module Personality
|
|
|
163
167
|
ORDER BY v.distance
|
|
164
168
|
SQL
|
|
165
169
|
else
|
|
166
|
-
|
|
170
|
+
db.execute(<<~SQL, [embedding.to_json, limit])
|
|
167
171
|
SELECT c.id, c.path, c.content, c.project, v.distance
|
|
168
172
|
FROM #{vec_table} v
|
|
169
173
|
INNER JOIN #{table} c ON c.id = v.chunk_id
|
data/lib/personality/init.rb
CHANGED
|
@@ -234,7 +234,7 @@ module Personality
|
|
|
234
234
|
end
|
|
235
235
|
|
|
236
236
|
def ensure_ollama_running
|
|
237
|
-
|
|
237
|
+
_, status = Open3.capture2e("ollama", "list")
|
|
238
238
|
return if status.success?
|
|
239
239
|
|
|
240
240
|
puts " #{pastel.yellow("starting")} ollama serve"
|
|
@@ -18,7 +18,7 @@ module Personality
|
|
|
18
18
|
|
|
19
19
|
def initialize
|
|
20
20
|
@server = ::MCP::Server.new(
|
|
21
|
-
name: "
|
|
21
|
+
name: "core",
|
|
22
22
|
version: Personality::VERSION
|
|
23
23
|
)
|
|
24
24
|
@server.server_context = {}
|
|
@@ -248,6 +248,120 @@ module Personality
|
|
|
248
248
|
end
|
|
249
249
|
end
|
|
250
250
|
|
|
251
|
+
# === Persona Tools ===
|
|
252
|
+
|
|
253
|
+
def register_persona_tools
|
|
254
|
+
@server.define_tool(
|
|
255
|
+
name: "cart.teach",
|
|
256
|
+
description: "Learn a persona from a training YAML file. Creates a .pcart cartridge and imports memories into the database.",
|
|
257
|
+
input_schema: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: {
|
|
260
|
+
training_file: {type: "string", description: "Path to the training YAML file"}
|
|
261
|
+
},
|
|
262
|
+
required: %w[training_file]
|
|
263
|
+
}
|
|
264
|
+
) do |training_file:, server_context:, **|
|
|
265
|
+
require_relative "../cart_manager"
|
|
266
|
+
manager = Personality::CartManager.new
|
|
267
|
+
cart = manager.create_from_training(training_file)
|
|
268
|
+
result = manager.import_memories(cart)
|
|
269
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate({
|
|
270
|
+
tag: cart.tag,
|
|
271
|
+
name: cart.name,
|
|
272
|
+
version: cart.version,
|
|
273
|
+
memory_count: cart.memory_count,
|
|
274
|
+
voice: cart.voice,
|
|
275
|
+
cart_path: cart.path,
|
|
276
|
+
imported: result
|
|
277
|
+
})}])
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
@server.define_tool(
|
|
281
|
+
name: "cart.show",
|
|
282
|
+
description: "Show persona details and LLM instructions for a cartridge.",
|
|
283
|
+
input_schema: {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {
|
|
286
|
+
tag: {type: "string", description: "Persona tag (e.g. bt7274). If omitted, shows active cart."}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
) do |server_context:, **opts|
|
|
290
|
+
require_relative "../cart_manager"
|
|
291
|
+
require_relative "../persona_builder"
|
|
292
|
+
manager = Personality::CartManager.new
|
|
293
|
+
tag = opts[:tag]
|
|
294
|
+
|
|
295
|
+
if tag
|
|
296
|
+
path = File.join(manager.carts_dir, "#{tag.downcase}.pcart")
|
|
297
|
+
raise "Cart not found: #{tag}" unless File.exist?(path)
|
|
298
|
+
else
|
|
299
|
+
carts = manager.list_carts
|
|
300
|
+
raise "No carts found" if carts.empty?
|
|
301
|
+
path = carts.first
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
cart = manager.load_cart(path)
|
|
305
|
+
builder = Personality::PersonaBuilder.new
|
|
306
|
+
identity = cart.preferences&.identity
|
|
307
|
+
|
|
308
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate({
|
|
309
|
+
tag: cart.tag,
|
|
310
|
+
name: cart.name,
|
|
311
|
+
version: cart.version,
|
|
312
|
+
type: identity&.type,
|
|
313
|
+
source: identity&.source,
|
|
314
|
+
tagline: identity&.tagline,
|
|
315
|
+
voice: cart.voice,
|
|
316
|
+
memory_count: cart.memory_count,
|
|
317
|
+
summary: builder.build_summary(cart),
|
|
318
|
+
instructions: builder.build_instructions(cart)
|
|
319
|
+
})}])
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
@server.define_tool(
|
|
323
|
+
name: "cart.instructions",
|
|
324
|
+
description: "Get the LLM persona instructions for the active or specified cart. Returns markdown formatted character instructions.",
|
|
325
|
+
input_schema: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: {
|
|
328
|
+
tag: {type: "string", description: "Persona tag (optional, uses first available if omitted)"}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
) do |server_context:, **opts|
|
|
332
|
+
require_relative "../cart_manager"
|
|
333
|
+
require_relative "../persona_builder"
|
|
334
|
+
manager = Personality::CartManager.new
|
|
335
|
+
tag = opts[:tag]
|
|
336
|
+
|
|
337
|
+
if tag
|
|
338
|
+
path = File.join(manager.carts_dir, "#{tag.downcase}.pcart")
|
|
339
|
+
raise "Cart not found: #{tag}" unless File.exist?(path)
|
|
340
|
+
else
|
|
341
|
+
carts = manager.list_carts
|
|
342
|
+
raise "No carts found" if carts.empty?
|
|
343
|
+
path = carts.first
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
cart = manager.load_cart(path)
|
|
347
|
+
builder = Personality::PersonaBuilder.new
|
|
348
|
+
instructions = builder.build_instructions(cart)
|
|
349
|
+
|
|
350
|
+
::MCP::Tool::Response.new([{type: "text", text: instructions}])
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
@server.define_tool(
|
|
354
|
+
name: "cart.carts",
|
|
355
|
+
description: "List available .pcart cartridge files with their metadata.",
|
|
356
|
+
input_schema: {type: "object", properties: {}}
|
|
357
|
+
) do |server_context:, **|
|
|
358
|
+
require_relative "../cart_manager"
|
|
359
|
+
manager = Personality::CartManager.new
|
|
360
|
+
carts = manager.list_carts.map { |p| manager.cart_info(p).merge(path: p) }
|
|
361
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate({carts: carts})}])
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
251
365
|
# === Resources ===
|
|
252
366
|
|
|
253
367
|
def register_resources
|
|
@@ -280,10 +394,47 @@ module Personality
|
|
|
280
394
|
end
|
|
281
395
|
end
|
|
282
396
|
|
|
397
|
+
def register_resource_tools
|
|
398
|
+
@server.define_tool(
|
|
399
|
+
name: "resource.read",
|
|
400
|
+
description: "Read an MCP resource by URI. Available resources: memory://subjects (subjects with counts), memory://stats (total memories, date range), memory://recent (last 10 memories).",
|
|
401
|
+
input_schema: {
|
|
402
|
+
type: "object",
|
|
403
|
+
properties: {
|
|
404
|
+
uri: {type: "string", description: "Resource URI (e.g. memory://subjects, memory://stats, memory://recent)"}
|
|
405
|
+
},
|
|
406
|
+
required: %w[uri]
|
|
407
|
+
}
|
|
408
|
+
) do |uri:, server_context:, **|
|
|
409
|
+
db = Personality::DB.connection
|
|
410
|
+
cart = Personality::Cart.active
|
|
411
|
+
|
|
412
|
+
result = case uri
|
|
413
|
+
when "memory://subjects"
|
|
414
|
+
rows = db.execute("SELECT subject, COUNT(*) AS count FROM memories WHERE cart_id = ? GROUP BY subject ORDER BY count DESC", [cart[:id]])
|
|
415
|
+
{subjects: rows.map { |r| {subject: r["subject"], count: r["count"]} }}
|
|
416
|
+
when "memory://stats"
|
|
417
|
+
total = db.execute("SELECT COUNT(*) AS c FROM memories WHERE cart_id = ?", [cart[:id]]).dig(0, "c") || 0
|
|
418
|
+
subjects = db.execute("SELECT COUNT(DISTINCT subject) AS c FROM memories WHERE cart_id = ?", [cart[:id]]).dig(0, "c") || 0
|
|
419
|
+
dates = db.execute("SELECT MIN(created_at) AS oldest, MAX(created_at) AS newest FROM memories WHERE cart_id = ?", [cart[:id]]).first
|
|
420
|
+
{cart: cart[:tag], total_memories: total, total_subjects: subjects, oldest: dates&.fetch("oldest", nil), newest: dates&.fetch("newest", nil)}
|
|
421
|
+
when "memory://recent"
|
|
422
|
+
rows = db.execute("SELECT id, subject, content, created_at FROM memories WHERE cart_id = ? ORDER BY created_at DESC LIMIT 10", [cart[:id]])
|
|
423
|
+
{memories: rows.map { |r| {id: r["id"], subject: r["subject"], content: r["content"], created_at: r["created_at"]} }}
|
|
424
|
+
else
|
|
425
|
+
{error: "Unknown resource: #{uri}"}
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
283
432
|
def register_tools
|
|
284
433
|
register_memory_tools
|
|
285
434
|
register_index_tools
|
|
286
435
|
register_cart_tools
|
|
436
|
+
register_persona_tools
|
|
437
|
+
register_resource_tools
|
|
287
438
|
end
|
|
288
439
|
|
|
289
440
|
def read_memory_resource(uri)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "mcp/transports/stdio"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "../tts"
|
|
7
|
+
|
|
8
|
+
module Personality
|
|
9
|
+
module MCP
|
|
10
|
+
class TtsServer
|
|
11
|
+
def self.run
|
|
12
|
+
new.start
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@server = ::MCP::Server.new(
|
|
17
|
+
name: "speech",
|
|
18
|
+
version: Personality::VERSION
|
|
19
|
+
)
|
|
20
|
+
@server.server_context = {}
|
|
21
|
+
register_tools
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start
|
|
25
|
+
transport = ::MCP::Transports::StdioTransport.new(@server)
|
|
26
|
+
transport.open
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def register_tools
|
|
32
|
+
register_speak
|
|
33
|
+
register_stop
|
|
34
|
+
register_voices
|
|
35
|
+
register_current
|
|
36
|
+
register_download
|
|
37
|
+
register_test
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def register_speak
|
|
41
|
+
@server.define_tool(
|
|
42
|
+
name: "speak",
|
|
43
|
+
description: "Speak text aloud using TTS. Synthesizes and plays audio in the background.",
|
|
44
|
+
input_schema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
text: {type: "string", description: "Text to speak aloud"},
|
|
48
|
+
voice: {type: "string", description: "Voice model name (optional, uses active voice if omitted)"}
|
|
49
|
+
},
|
|
50
|
+
required: %w[text]
|
|
51
|
+
}
|
|
52
|
+
) do |text:, server_context:, **opts|
|
|
53
|
+
result = Personality::TTS.speak(text, voice: opts[:voice])
|
|
54
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def register_stop
|
|
59
|
+
@server.define_tool(
|
|
60
|
+
name: "stop",
|
|
61
|
+
description: "Stop currently playing TTS audio.",
|
|
62
|
+
input_schema: {type: "object", properties: {}}
|
|
63
|
+
) do |server_context:, **|
|
|
64
|
+
stopped = Personality::TTS.stop_current
|
|
65
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate({stopped: stopped})}])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def register_voices
|
|
70
|
+
@server.define_tool(
|
|
71
|
+
name: "voices",
|
|
72
|
+
description: "List all installed TTS voice models.",
|
|
73
|
+
input_schema: {type: "object", properties: {}}
|
|
74
|
+
) do |server_context:, **|
|
|
75
|
+
voices = Personality::TTS.list_voices
|
|
76
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate({voices: voices, count: voices.size})}])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def register_current
|
|
81
|
+
@server.define_tool(
|
|
82
|
+
name: "current",
|
|
83
|
+
description: "Show the currently active TTS voice and whether it is installed.",
|
|
84
|
+
input_schema: {type: "object", properties: {}}
|
|
85
|
+
) do |server_context:, **|
|
|
86
|
+
voice = Personality::TTS.active_voice
|
|
87
|
+
installed = !Personality::TTS.find_voice(voice).nil?
|
|
88
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate({voice: voice, installed: installed})}])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def register_download
|
|
93
|
+
@server.define_tool(
|
|
94
|
+
name: "download",
|
|
95
|
+
description: "Download a piper TTS voice model from HuggingFace.",
|
|
96
|
+
input_schema: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
voice: {type: "string", description: "Voice name to download (e.g. en_US-lessac-medium)"}
|
|
100
|
+
},
|
|
101
|
+
required: %w[voice]
|
|
102
|
+
}
|
|
103
|
+
) do |voice:, server_context:, **|
|
|
104
|
+
result = Personality::TTS.download_voice(voice)
|
|
105
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def register_test
|
|
110
|
+
@server.define_tool(
|
|
111
|
+
name: "test",
|
|
112
|
+
description: "Test a TTS voice with sample text. Speaks and waits for completion.",
|
|
113
|
+
input_schema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
voice: {type: "string", description: "Voice to test (optional, uses active voice if omitted)"}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
) do |server_context:, **opts|
|
|
120
|
+
result = Personality::TTS.speak_and_wait("Hello! This is a test of the text to speech system.", voice: opts[:voice])
|
|
121
|
+
::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/personality/memory.rb
CHANGED
|
@@ -39,8 +39,8 @@ module Personality
|
|
|
39
39
|
|
|
40
40
|
db = DB.connection
|
|
41
41
|
|
|
42
|
-
if subject
|
|
43
|
-
|
|
42
|
+
rows = if subject
|
|
43
|
+
db.execute(<<~SQL, [embedding.to_json, limit, cart_id, subject])
|
|
44
44
|
SELECT m.id, m.subject, m.content, m.metadata, m.created_at, v.distance
|
|
45
45
|
FROM vec_memories v
|
|
46
46
|
INNER JOIN memories m ON m.id = v.memory_id
|
|
@@ -49,7 +49,7 @@ module Personality
|
|
|
49
49
|
ORDER BY v.distance
|
|
50
50
|
SQL
|
|
51
51
|
else
|
|
52
|
-
|
|
52
|
+
db.execute(<<~SQL, [embedding.to_json, limit, cart_id])
|
|
53
53
|
SELECT m.id, m.subject, m.content, m.metadata, m.created_at, v.distance
|
|
54
54
|
FROM vec_memories v
|
|
55
55
|
INNER JOIN memories m ON m.id = v.memory_id
|
|
@@ -66,13 +66,13 @@ module Personality
|
|
|
66
66
|
def search(subject: nil, limit: 20)
|
|
67
67
|
db = DB.connection
|
|
68
68
|
|
|
69
|
-
if subject
|
|
70
|
-
|
|
69
|
+
rows = if subject
|
|
70
|
+
db.execute(
|
|
71
71
|
"SELECT id, subject, content, created_at FROM memories WHERE cart_id = ? AND subject LIKE ? ORDER BY created_at DESC LIMIT ?",
|
|
72
72
|
[cart_id, "%#{subject}%", limit]
|
|
73
73
|
)
|
|
74
74
|
else
|
|
75
|
-
|
|
75
|
+
db.execute(
|
|
76
76
|
"SELECT id, subject, content, created_at FROM memories WHERE cart_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
77
77
|
[cart_id, limit]
|
|
78
78
|
)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Personality
|
|
4
|
+
# Builds LLM persona instructions from cartridge memories.
|
|
5
|
+
#
|
|
6
|
+
# Groups memories by their dot-notation subject taxonomy and formats
|
|
7
|
+
# them into structured markdown that teaches the LLM its character.
|
|
8
|
+
#
|
|
9
|
+
class PersonaBuilder
|
|
10
|
+
CATEGORY_TITLES = {
|
|
11
|
+
"identity" => "Identity",
|
|
12
|
+
"trait" => "Personality Traits",
|
|
13
|
+
"belief" => "Beliefs & Values",
|
|
14
|
+
"speech" => "Speech Patterns",
|
|
15
|
+
"knowledge" => "Knowledge",
|
|
16
|
+
"relationship" => "Relationships",
|
|
17
|
+
"behavior" => "Behaviors",
|
|
18
|
+
"emotion" => "Emotional Tendencies",
|
|
19
|
+
"goal" => "Goals & Motivations",
|
|
20
|
+
"memory" => "Background & Memories",
|
|
21
|
+
"quirk" => "Quirks & Mannerisms",
|
|
22
|
+
"protocol" => "Protocols",
|
|
23
|
+
"capability" => "Capabilities",
|
|
24
|
+
"logic" => "Logic & Reasoning",
|
|
25
|
+
"quote" => "Iconic Quotes",
|
|
26
|
+
"history" => "History & Backstory"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Preferred display order for self.* sub-categories
|
|
30
|
+
SECTION_ORDER = %w[
|
|
31
|
+
identity trait protocol speech capability relationship
|
|
32
|
+
quote logic belief behavior emotion goal memory quirk knowledge history
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Build full persona instructions from a cartridge.
|
|
36
|
+
#
|
|
37
|
+
# @param cart [Cartridge] Loaded cartridge with memories and preferences
|
|
38
|
+
# @return [String] Formatted markdown instructions
|
|
39
|
+
def self.build_instructions(cart)
|
|
40
|
+
new.build_instructions(cart)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param cart [Cartridge]
|
|
44
|
+
# @return [String]
|
|
45
|
+
def build_instructions(cart)
|
|
46
|
+
return "" if cart.memories.nil? || cart.memories.empty?
|
|
47
|
+
|
|
48
|
+
groups = group_by_top_level(cart.memories)
|
|
49
|
+
lines = []
|
|
50
|
+
|
|
51
|
+
# Header
|
|
52
|
+
lines << "## Your Character\n\n"
|
|
53
|
+
identity = cart.preferences&.identity
|
|
54
|
+
if identity && !identity.name.empty?
|
|
55
|
+
lines << "You are **#{identity.name}**"
|
|
56
|
+
lines << ", a #{identity.type}" unless identity.type.empty?
|
|
57
|
+
lines << ".\n\n"
|
|
58
|
+
elsif !cart.tag.to_s.empty?
|
|
59
|
+
lines << "You are roleplaying as **#{cart.tag}**.\n\n"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
lines << "> \"#{identity.tagline}\"\n\n" if identity && !identity.tagline.empty?
|
|
63
|
+
|
|
64
|
+
lines << "Stay in character at all times. Use the personality traits, "
|
|
65
|
+
lines << "speech patterns, and knowledge provided below.\n"
|
|
66
|
+
|
|
67
|
+
# self.* memories (the bulk of persona definition)
|
|
68
|
+
if groups.key?("self")
|
|
69
|
+
lines.concat(format_self_memories(groups.delete("self")))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# user.* memories
|
|
73
|
+
if groups.key?("user")
|
|
74
|
+
lines << "\n### User Interaction\n\n"
|
|
75
|
+
groups.delete("user").each { |m| lines << "- #{m.content}\n" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# meta.* memories
|
|
79
|
+
if groups.key?("meta")
|
|
80
|
+
lines << "\n### Meta Information\n\n"
|
|
81
|
+
groups.delete("meta").each { |m| lines << "- #{m.content}\n" }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# identity.* already covered in header
|
|
85
|
+
groups.delete("identity")
|
|
86
|
+
|
|
87
|
+
# Remaining groups
|
|
88
|
+
groups.sort.each do |category, mems|
|
|
89
|
+
title = CATEGORY_TITLES.fetch(category, category.capitalize)
|
|
90
|
+
lines << "\n### #{title}\n\n"
|
|
91
|
+
mems.each { |m| lines << "- #{m.content}\n" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
lines.join
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build a greeting from the cart's greeting memory.
|
|
98
|
+
#
|
|
99
|
+
# @param cart [Cartridge]
|
|
100
|
+
# @param user_name [String, nil]
|
|
101
|
+
# @return [String]
|
|
102
|
+
def build_greeting(cart, user_name: nil)
|
|
103
|
+
template = cart.memories&.find { |m|
|
|
104
|
+
m.subject.downcase.include?("greeting") || m.subject.downcase.include?("salutation")
|
|
105
|
+
}&.content
|
|
106
|
+
|
|
107
|
+
unless template
|
|
108
|
+
name = cart.preferences&.identity&.name
|
|
109
|
+
name = cart.tag if name.nil? || name.empty?
|
|
110
|
+
return "Hello, I am #{name}."
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
template
|
|
114
|
+
.gsub("{{USER_ID}}", user_name || "User")
|
|
115
|
+
.gsub("{{user}}", user_name || "User")
|
|
116
|
+
.gsub("{{TIME_GREETING}}", time_greeting)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Build a brief summary for display.
|
|
120
|
+
#
|
|
121
|
+
# @param cart [Cartridge]
|
|
122
|
+
# @return [String]
|
|
123
|
+
def build_summary(cart)
|
|
124
|
+
parts = []
|
|
125
|
+
identity = cart.preferences&.identity
|
|
126
|
+
|
|
127
|
+
if identity && !identity.name.empty?
|
|
128
|
+
parts << identity.name
|
|
129
|
+
elsif !cart.tag.to_s.empty?
|
|
130
|
+
parts << cart.tag
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
parts << "(#{identity.type})" if identity && !identity.type.empty?
|
|
134
|
+
parts << "v#{cart.version}" if cart.version && !cart.version.empty?
|
|
135
|
+
|
|
136
|
+
parts.empty? ? "Persona loaded" : parts.join(" ")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def group_by_top_level(memories)
|
|
142
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
143
|
+
memories.each do |mem|
|
|
144
|
+
category = mem.subject.split(".").first || "other"
|
|
145
|
+
groups[category] << mem
|
|
146
|
+
end
|
|
147
|
+
groups
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def format_self_memories(memories)
|
|
151
|
+
lines = []
|
|
152
|
+
|
|
153
|
+
# Sub-group by second level (trait, belief, speech, etc.)
|
|
154
|
+
sub_groups = Hash.new { |h, k| h[k] = [] }
|
|
155
|
+
memories.each do |mem|
|
|
156
|
+
parts = mem.subject.split(".")
|
|
157
|
+
sub_cat = (parts.length > 1) ? parts[1] : "general"
|
|
158
|
+
sub_groups[sub_cat] << mem
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Ordered sections first
|
|
162
|
+
seen = Set.new
|
|
163
|
+
SECTION_ORDER.each do |sub_cat|
|
|
164
|
+
next unless sub_groups.key?(sub_cat)
|
|
165
|
+
title = CATEGORY_TITLES.fetch(sub_cat, sub_cat.capitalize)
|
|
166
|
+
lines << "\n### #{title}\n\n"
|
|
167
|
+
sub_groups[sub_cat].each { |m| lines << "- #{m.content}\n" }
|
|
168
|
+
seen << sub_cat
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Remaining sub-categories
|
|
172
|
+
sub_groups.sort.each do |sub_cat, mems|
|
|
173
|
+
next if seen.include?(sub_cat)
|
|
174
|
+
title = CATEGORY_TITLES.fetch(sub_cat, sub_cat.capitalize)
|
|
175
|
+
lines << "\n### #{title}\n\n"
|
|
176
|
+
mems.each { |m| lines << "- #{m.content}\n" }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
lines
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def time_greeting
|
|
183
|
+
hour = Time.now.hour
|
|
184
|
+
if hour < 12
|
|
185
|
+
"Good morning"
|
|
186
|
+
elsif hour < 17
|
|
187
|
+
"Good afternoon"
|
|
188
|
+
else
|
|
189
|
+
"Good evening"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Personality
|
|
7
|
+
# Parsed training memory
|
|
8
|
+
TrainingMemory = Struct.new(:subject, :content, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Parsed training document
|
|
11
|
+
TrainingDocument = Struct.new(:source, :format, :tag, :version, :memories, :preferences, keyword_init: true) do
|
|
12
|
+
def count
|
|
13
|
+
memories.size
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Parses YAML and JSON training files to extract persona memories.
|
|
18
|
+
#
|
|
19
|
+
# Training files define a persona's identity, traits, speech patterns,
|
|
20
|
+
# protocols, and other memories using a dot-notation subject taxonomy:
|
|
21
|
+
#
|
|
22
|
+
# self.identity.* - Core self-definition
|
|
23
|
+
# self.trait.* - Personality characteristics
|
|
24
|
+
# self.protocol.* - Rules of behavior
|
|
25
|
+
# self.speech.* - Communication patterns
|
|
26
|
+
# self.quote.* - Iconic lines
|
|
27
|
+
# user.identity.* - How to address the user
|
|
28
|
+
# meta.system.* - Meta configuration
|
|
29
|
+
#
|
|
30
|
+
class TrainingParser
|
|
31
|
+
SUPPORTED_EXTENSIONS = %w[.yml .yaml .json .jsonld].freeze
|
|
32
|
+
|
|
33
|
+
# Parse a training file into a TrainingDocument.
|
|
34
|
+
#
|
|
35
|
+
# @param path [String] Path to the training file
|
|
36
|
+
# @return [TrainingDocument]
|
|
37
|
+
# @raise [ArgumentError] if file format is unsupported
|
|
38
|
+
# @raise [Errno::ENOENT] if file doesn't exist
|
|
39
|
+
def parse_file(path)
|
|
40
|
+
path = File.expand_path(path)
|
|
41
|
+
raise Errno::ENOENT, "Training file not found: #{path}" unless File.exist?(path)
|
|
42
|
+
|
|
43
|
+
ext = File.extname(path).downcase
|
|
44
|
+
content = File.read(path, encoding: "utf-8")
|
|
45
|
+
|
|
46
|
+
tag, version, memories, preferences = case ext
|
|
47
|
+
when ".yml", ".yaml" then parse_yaml(content)
|
|
48
|
+
when ".json", ".jsonld" then parse_json(content)
|
|
49
|
+
else raise ArgumentError, "Unsupported file format: #{ext}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
TrainingDocument.new(
|
|
53
|
+
source: path,
|
|
54
|
+
format: ext.delete_prefix("."),
|
|
55
|
+
tag: tag,
|
|
56
|
+
version: version,
|
|
57
|
+
memories: memories,
|
|
58
|
+
preferences: preferences
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# List training files in a directory.
|
|
63
|
+
#
|
|
64
|
+
# @param directory [String] Directory to scan
|
|
65
|
+
# @return [Array<String>] Sorted list of training file paths
|
|
66
|
+
def list_files(directory)
|
|
67
|
+
return [] unless Dir.exist?(directory)
|
|
68
|
+
|
|
69
|
+
Dir.glob(File.join(directory, "*.{yml,yaml,json,jsonld}"))
|
|
70
|
+
.sort_by { |p| File.basename(p).downcase }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Validate a training file.
|
|
74
|
+
#
|
|
75
|
+
# @param path [String] Path to the training file
|
|
76
|
+
# @return [Array(Boolean, String)] [valid?, message]
|
|
77
|
+
def validate(path)
|
|
78
|
+
doc = parse_file(path)
|
|
79
|
+
if doc.count == 0
|
|
80
|
+
[false, "No memories found in file"]
|
|
81
|
+
else
|
|
82
|
+
[true, "Valid: #{doc.count} memories, tag=#{doc.tag}"]
|
|
83
|
+
end
|
|
84
|
+
rescue => e
|
|
85
|
+
[false, e.message]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def parse_yaml(content)
|
|
91
|
+
data = YAML.safe_load(content, permitted_classes: [Date, Time])
|
|
92
|
+
raise ArgumentError, "YAML root must be a hash" unless data.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
tag = data.fetch("tag", "").to_s
|
|
95
|
+
version = data.fetch("version", "").to_s
|
|
96
|
+
preferences = data.fetch("preferences", {})
|
|
97
|
+
preferences = {} unless preferences.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
memories = []
|
|
100
|
+
|
|
101
|
+
# Legacy identity section
|
|
102
|
+
identity = data.fetch("identity", {})
|
|
103
|
+
if identity.is_a?(Hash)
|
|
104
|
+
identity.each do |key, value|
|
|
105
|
+
memories << TrainingMemory.new(subject: "identity.#{key}", content: value.to_s) if value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Memories section
|
|
110
|
+
parse_memory_list(data.fetch("memories", []), memories)
|
|
111
|
+
|
|
112
|
+
[tag, version, memories, preferences]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_json(content)
|
|
116
|
+
data = JSON.parse(content)
|
|
117
|
+
raise ArgumentError, "JSON root must be an object" unless data.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
tag = data.fetch("tag", "").to_s
|
|
120
|
+
version = data.fetch("version", "").to_s
|
|
121
|
+
preferences = data.fetch("preferences", {})
|
|
122
|
+
preferences = {} unless preferences.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
memories = []
|
|
125
|
+
|
|
126
|
+
# Top-level identity fields
|
|
127
|
+
%w[name description personality purpose].each do |key|
|
|
128
|
+
value = data[key]
|
|
129
|
+
memories << TrainingMemory.new(subject: "identity.#{key}", content: value) if value.is_a?(String)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Memories array
|
|
133
|
+
parse_memory_list(data.fetch("memories", []), memories)
|
|
134
|
+
|
|
135
|
+
# Knowledge graph
|
|
136
|
+
knowledge = data.fetch("knowledge", [])
|
|
137
|
+
if knowledge.is_a?(Array)
|
|
138
|
+
knowledge.each do |item|
|
|
139
|
+
next unless item.is_a?(Hash)
|
|
140
|
+
subject = item.fetch("@type", "knowledge.general").to_s
|
|
141
|
+
mem_content = (item["description"] || item["value"]).to_s
|
|
142
|
+
memories << TrainingMemory.new(subject: subject, content: mem_content) unless mem_content.empty?
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
[tag, version, memories, preferences]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_memory_list(list, memories)
|
|
150
|
+
return unless list.is_a?(Array)
|
|
151
|
+
|
|
152
|
+
list.each do |item|
|
|
153
|
+
next unless item.is_a?(Hash)
|
|
154
|
+
subject = item["subject"].to_s
|
|
155
|
+
content = item["content"]
|
|
156
|
+
next if subject.empty? || content.nil?
|
|
157
|
+
|
|
158
|
+
# Handle list content (e.g., addressed_as: [Pilot, Pilot Cooper])
|
|
159
|
+
content = content.map(&:to_s).join(", ") if content.is_a?(Array)
|
|
160
|
+
content = content.to_s
|
|
161
|
+
next if content.empty?
|
|
162
|
+
|
|
163
|
+
memories << TrainingMemory.new(subject: subject, content: content)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
data/lib/personality/tts.rb
CHANGED
data/lib/personality/version.rb
CHANGED
data/lib/personality.rb
CHANGED
|
@@ -11,6 +11,9 @@ require_relative "personality/chunker"
|
|
|
11
11
|
require_relative "personality/hooks"
|
|
12
12
|
require_relative "personality/context"
|
|
13
13
|
require_relative "personality/cart"
|
|
14
|
+
require_relative "personality/cart_manager"
|
|
15
|
+
require_relative "personality/training_parser"
|
|
16
|
+
require_relative "personality/persona_builder"
|
|
14
17
|
require_relative "personality/memory"
|
|
15
18
|
require_relative "personality/tts"
|
|
16
19
|
require_relative "personality/indexer"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: personality
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- aladac
|
|
@@ -135,6 +135,20 @@ dependencies:
|
|
|
135
135
|
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: 0.9.1
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: rubyzip
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: 2.4.1
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: 2.4.1
|
|
138
152
|
- !ruby/object:Gem::Dependency
|
|
139
153
|
name: toml-rb
|
|
140
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -191,6 +205,20 @@ dependencies:
|
|
|
191
205
|
- - "~>"
|
|
192
206
|
- !ruby/object:Gem::Version
|
|
193
207
|
version: 3.13.2
|
|
208
|
+
- !ruby/object:Gem::Dependency
|
|
209
|
+
name: simplecov
|
|
210
|
+
requirement: !ruby/object:Gem::Requirement
|
|
211
|
+
requirements:
|
|
212
|
+
- - "~>"
|
|
213
|
+
- !ruby/object:Gem::Version
|
|
214
|
+
version: 0.22.0
|
|
215
|
+
type: :development
|
|
216
|
+
prerelease: false
|
|
217
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
218
|
+
requirements:
|
|
219
|
+
- - "~>"
|
|
220
|
+
- !ruby/object:Gem::Version
|
|
221
|
+
version: 0.22.0
|
|
194
222
|
- !ruby/object:Gem::Dependency
|
|
195
223
|
name: standard
|
|
196
224
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -212,6 +240,7 @@ email:
|
|
|
212
240
|
executables:
|
|
213
241
|
- psn
|
|
214
242
|
- psn-mcp
|
|
243
|
+
- psn-tts
|
|
215
244
|
extensions: []
|
|
216
245
|
extra_rdoc_files: []
|
|
217
246
|
files:
|
|
@@ -223,8 +252,10 @@ files:
|
|
|
223
252
|
- docs/mcp-ruby-sdk.md
|
|
224
253
|
- exe/psn
|
|
225
254
|
- exe/psn-mcp
|
|
255
|
+
- exe/psn-tts
|
|
226
256
|
- lib/personality.rb
|
|
227
257
|
- lib/personality/cart.rb
|
|
258
|
+
- lib/personality/cart_manager.rb
|
|
228
259
|
- lib/personality/chunker.rb
|
|
229
260
|
- lib/personality/cli.rb
|
|
230
261
|
- lib/personality/cli/cart.rb
|
|
@@ -240,7 +271,10 @@ files:
|
|
|
240
271
|
- lib/personality/indexer.rb
|
|
241
272
|
- lib/personality/init.rb
|
|
242
273
|
- lib/personality/mcp/server.rb
|
|
274
|
+
- lib/personality/mcp/tts_server.rb
|
|
243
275
|
- lib/personality/memory.rb
|
|
276
|
+
- lib/personality/persona_builder.rb
|
|
277
|
+
- lib/personality/training_parser.rb
|
|
244
278
|
- lib/personality/tts.rb
|
|
245
279
|
- lib/personality/version.rb
|
|
246
280
|
homepage: https://github.com/aladac/personality
|
|
@@ -263,7 +297,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
263
297
|
- !ruby/object:Gem::Version
|
|
264
298
|
version: '0'
|
|
265
299
|
requirements: []
|
|
266
|
-
rubygems_version: 4.0.
|
|
300
|
+
rubygems_version: 4.0.6
|
|
267
301
|
specification_version: 4
|
|
268
302
|
summary: Infrastructure layer for Claude Code
|
|
269
303
|
test_files: []
|