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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8995b66c26162fbdbb780899f5e8e1f97fe3bdf890e587297bb41058c1f3c0f
4
- data.tar.gz: 326558e3feb47e8b95000b8db597d3a0fd677d7a2d04f7ae9b18353559819ae8
3
+ metadata.gz: 6d10c8c7ae61bc430f61154cdc2760ece25c3868a70b3eafd1405830679ca348
4
+ data.tar.gz: 788487ea2930b59b020c8aee337ad8b1eea4448ff43ded86d23c4c82616871c6
5
5
  SHA512:
6
- metadata.gz: 4ab0e534ca8179f15f9c824a3ea08b1d52db29291323749c2d3797b4467eef818758d7202eaacd51b8988ecff47c74531abe581f4304fa1ed5935eecb1db91bd
7
- data.tar.gz: cac3b00f2335b80f737ab3c3e08c26d0c640d196261bc680f818843819c5fedb6df964a922e2aa1bf56923d385ef52538b02dda4a6815bf0300e7412965894ac
6
+ metadata.gz: c6c572c470a06ef00997edc63a2987aecc40864d480ccad7577b431659634d43e365bda551906a2c43c759491757e751735e5e3baedfd874c78fad592eb9a446
7
+ data.tar.gz: 35949f8a0ebaea35733f45b66fb6f2eb90ea6a9c74bbf8da3dd101ddaa3f633b314a900d47be30b63f2c8b29a02d9306775930238c781c8750d5ea2ffb757a87
data/exe/psn-tts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "personality"
5
+ require "personality/mcp/tts_server"
6
+
7
+ Personality::MCP::TtsServer.run
@@ -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
@@ -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
- return unless transcript_path && File.exist?(transcript_path)
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
@@ -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 = extensions ? extensions.map { |e| e.start_with?(".") ? e : ".#{e}" }.to_set : CODE_EXTENSIONS
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
- rows = db.execute(<<~SQL, [embedding.to_json, limit, project])
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
- rows = db.execute(<<~SQL, [embedding.to_json, limit])
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
@@ -234,7 +234,7 @@ module Personality
234
234
  end
235
235
 
236
236
  def ensure_ollama_running
237
- stdout, status = Open3.capture2e("ollama", "list")
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: "personality",
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
@@ -39,8 +39,8 @@ module Personality
39
39
 
40
40
  db = DB.connection
41
41
 
42
- if subject
43
- rows = db.execute(<<~SQL, [embedding.to_json, limit, cart_id, subject])
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
- rows = db.execute(<<~SQL, [embedding.to_json, limit, cart_id])
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
- rows = db.execute(
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
- rows = db.execute(
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
@@ -31,7 +31,7 @@ module Personality
31
31
  FileUtils.mkdir_p(DATA_DIR)
32
32
 
33
33
  # Synthesize to WAV
34
- stdout, stderr, status = Open3.capture3(
34
+ _, stderr, status = Open3.capture3(
35
35
  piper_bin, "--model", model_path, "--output_file", WAV_FILE,
36
36
  stdin_data: text
37
37
  )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Personality
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
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.0
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.8
300
+ rubygems_version: 4.0.6
267
301
  specification_version: 4
268
302
  summary: Infrastructure layer for Claude Code
269
303
  test_files: []