personality 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require "mcp/transports/stdio"
5
+ require "json"
6
+ require_relative "../db"
7
+ require_relative "../memory"
8
+ require_relative "../indexer"
9
+ require_relative "../cart"
10
+
11
+ module Personality
12
+ module MCP
13
+ class Server
14
+ def self.run
15
+ DB.migrate!
16
+ new.start
17
+ end
18
+
19
+ def initialize
20
+ @server = ::MCP::Server.new(
21
+ name: "personality",
22
+ version: Personality::VERSION
23
+ )
24
+ @server.server_context = {}
25
+ register_tools
26
+ register_resources
27
+ end
28
+
29
+ def start
30
+ transport = ::MCP::Transports::StdioTransport.new(@server)
31
+ transport.open
32
+ end
33
+
34
+ private
35
+
36
+ def tool_response(result)
37
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
38
+ end
39
+
40
+ # === Memory Tools ===
41
+
42
+ def register_memory_tools
43
+ @server.define_tool(
44
+ name: "memory.store",
45
+ description: "Store a memory with subject and content. Automatically generates embedding.",
46
+ input_schema: {
47
+ type: "object",
48
+ properties: {
49
+ subject: {type: "string", description: "Memory subject/category"},
50
+ content: {type: "string", description: "Memory content to store"},
51
+ metadata: {type: "object", description: "Additional metadata"}
52
+ },
53
+ required: %w[subject content]
54
+ }
55
+ ) do |subject:, content:, server_context:, **opts|
56
+ result = Memory.new.store(subject: subject, content: content, metadata: opts.fetch(:metadata, {}))
57
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
58
+ end
59
+
60
+ @server.define_tool(
61
+ name: "memory.recall",
62
+ description: "Recall memories by semantic similarity to a query.",
63
+ input_schema: {
64
+ type: "object",
65
+ properties: {
66
+ query: {type: "string", description: "Query to search for"},
67
+ limit: {type: "integer", description: "Max results (default: 5)"},
68
+ subject: {type: "string", description: "Filter by subject"}
69
+ },
70
+ required: %w[query]
71
+ }
72
+ ) do |query:, server_context:, **opts|
73
+ result = Memory.new.recall(query: query, limit: opts.fetch(:limit, 5), subject: opts[:subject])
74
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
75
+ end
76
+
77
+ @server.define_tool(
78
+ name: "memory.search",
79
+ description: "Search memories by subject or metadata.",
80
+ input_schema: {
81
+ type: "object",
82
+ properties: {
83
+ subject: {type: "string", description: "Subject to search"},
84
+ limit: {type: "integer", description: "Max results"}
85
+ }
86
+ }
87
+ ) do |server_context:, **opts|
88
+ result = Memory.new.search(subject: opts[:subject], limit: opts.fetch(:limit, 20))
89
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
90
+ end
91
+
92
+ @server.define_tool(
93
+ name: "memory.forget",
94
+ description: "Delete a memory by ID.",
95
+ input_schema: {
96
+ type: "object",
97
+ properties: {
98
+ id: {type: "integer", description: "Memory ID to delete"}
99
+ },
100
+ required: %w[id]
101
+ }
102
+ ) do |id:, server_context:, **|
103
+ result = Memory.new.forget(id: id)
104
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
105
+ end
106
+
107
+ @server.define_tool(
108
+ name: "memory.list",
109
+ description: "List all memory subjects and counts.",
110
+ input_schema: {type: "object", properties: {}}
111
+ ) do |server_context:, **|
112
+ result = Memory.new.list
113
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
114
+ end
115
+ end
116
+
117
+ # === Index Tools ===
118
+
119
+ def register_index_tools
120
+ @server.define_tool(
121
+ name: "index.code",
122
+ description: "Index code files in a directory for semantic search.",
123
+ input_schema: {
124
+ type: "object",
125
+ properties: {
126
+ path: {type: "string", description: "Directory path to index"},
127
+ project: {type: "string", description: "Project name for grouping"},
128
+ extensions: {type: "array", items: {type: "string"}, description: "File extensions to include"}
129
+ },
130
+ required: %w[path]
131
+ }
132
+ ) do |path:, server_context:, **opts|
133
+ result = Indexer.new.index_code(path: path, project: opts[:project], extensions: opts[:extensions])
134
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
135
+ end
136
+
137
+ @server.define_tool(
138
+ name: "index.docs",
139
+ description: "Index documentation files for semantic search.",
140
+ input_schema: {
141
+ type: "object",
142
+ properties: {
143
+ path: {type: "string", description: "Directory path to index"},
144
+ project: {type: "string", description: "Project name"}
145
+ },
146
+ required: %w[path]
147
+ }
148
+ ) do |path:, server_context:, **opts|
149
+ result = Indexer.new.index_docs(path: path, project: opts[:project])
150
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
151
+ end
152
+
153
+ @server.define_tool(
154
+ name: "index.search",
155
+ description: "Search indexed code and docs by semantic similarity.",
156
+ input_schema: {
157
+ type: "object",
158
+ properties: {
159
+ query: {type: "string", description: "Search query"},
160
+ type: {type: "string", enum: %w[code docs all], description: "What to search"},
161
+ project: {type: "string", description: "Filter by project"},
162
+ limit: {type: "integer", description: "Max results"}
163
+ },
164
+ required: %w[query]
165
+ }
166
+ ) do |query:, server_context:, **opts|
167
+ result = Indexer.new.search(
168
+ query: query,
169
+ type: (opts[:type] || "all").to_sym,
170
+ project: opts[:project],
171
+ limit: opts.fetch(:limit, 10)
172
+ )
173
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
174
+ end
175
+
176
+ @server.define_tool(
177
+ name: "index.status",
178
+ description: "Show indexing status and statistics.",
179
+ input_schema: {
180
+ type: "object",
181
+ properties: {
182
+ project: {type: "string", description: "Filter by project"}
183
+ }
184
+ }
185
+ ) do |server_context:, **opts|
186
+ result = Indexer.new.status(project: opts[:project])
187
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
188
+ end
189
+
190
+ @server.define_tool(
191
+ name: "index.clear",
192
+ description: "Clear index for a project or all.",
193
+ input_schema: {
194
+ type: "object",
195
+ properties: {
196
+ project: {type: "string", description: "Project to clear (omit for all)"},
197
+ type: {type: "string", enum: %w[code docs all], description: "What to clear"}
198
+ }
199
+ }
200
+ ) do |server_context:, **opts|
201
+ result = Indexer.new.clear(project: opts[:project], type: (opts[:type] || "all").to_sym)
202
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
203
+ end
204
+ end
205
+
206
+ # === Cart Tools ===
207
+
208
+ def register_cart_tools
209
+ @server.define_tool(
210
+ name: "cart.list",
211
+ description: "List all personas.",
212
+ input_schema: {type: "object", properties: {}}
213
+ ) do |server_context:, **|
214
+ result = {carts: Cart.list}
215
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
216
+ end
217
+
218
+ @server.define_tool(
219
+ name: "cart.use",
220
+ description: "Switch active persona.",
221
+ input_schema: {
222
+ type: "object",
223
+ properties: {
224
+ tag: {type: "string", description: "Persona tag"}
225
+ },
226
+ required: %w[tag]
227
+ }
228
+ ) do |tag:, server_context:, **|
229
+ result = Cart.use(tag)
230
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
231
+ end
232
+
233
+ @server.define_tool(
234
+ name: "cart.create",
235
+ description: "Create a new persona.",
236
+ input_schema: {
237
+ type: "object",
238
+ properties: {
239
+ tag: {type: "string", description: "Persona tag"},
240
+ name: {type: "string", description: "Display name"},
241
+ type: {type: "string", description: "Persona type"}
242
+ },
243
+ required: %w[tag]
244
+ }
245
+ ) do |tag:, server_context:, **opts|
246
+ result = Cart.create(tag, name: opts[:name], type: opts[:type])
247
+ ::MCP::Tool::Response.new([{type: "text", text: JSON.generate(result)}])
248
+ end
249
+ end
250
+
251
+ # === Resources ===
252
+
253
+ def register_resources
254
+ resources = [
255
+ ::MCP::Resource.new(
256
+ uri: "memory://subjects",
257
+ name: "memory-subjects",
258
+ description: "All memory subjects with counts",
259
+ mime_type: "application/json"
260
+ ),
261
+ ::MCP::Resource.new(
262
+ uri: "memory://stats",
263
+ name: "memory-stats",
264
+ description: "Total memories, subjects, date range",
265
+ mime_type: "application/json"
266
+ ),
267
+ ::MCP::Resource.new(
268
+ uri: "memory://recent",
269
+ name: "memory-recent",
270
+ description: "Most recent 10 memories",
271
+ mime_type: "application/json"
272
+ )
273
+ ]
274
+ @server.resources = resources
275
+
276
+ @server.resources_read_handler do |params|
277
+ uri = params[:uri]
278
+ result = read_memory_resource(uri)
279
+ [{uri: uri, mimeType: "application/json", text: JSON.generate(result)}]
280
+ end
281
+ end
282
+
283
+ def register_tools
284
+ register_memory_tools
285
+ register_index_tools
286
+ register_cart_tools
287
+ end
288
+
289
+ def read_memory_resource(uri)
290
+ db = DB.connection
291
+ cart = Cart.active
292
+
293
+ case uri
294
+ when "memory://subjects"
295
+ rows = db.execute("SELECT subject, COUNT(*) AS count FROM memories WHERE cart_id = ? GROUP BY subject ORDER BY count DESC", [cart[:id]])
296
+ {subjects: rows.map { |r| {subject: r["subject"], count: r["count"]} }}
297
+
298
+ when "memory://stats"
299
+ total = db.execute("SELECT COUNT(*) AS c FROM memories WHERE cart_id = ?", [cart[:id]]).dig(0, "c") || 0
300
+ subjects = db.execute("SELECT COUNT(DISTINCT subject) AS c FROM memories WHERE cart_id = ?", [cart[:id]]).dig(0, "c") || 0
301
+ dates = db.execute("SELECT MIN(created_at) AS oldest, MAX(created_at) AS newest FROM memories WHERE cart_id = ?", [cart[:id]]).first
302
+ {cart: cart[:tag], total_memories: total, total_subjects: subjects, oldest: dates&.fetch("oldest", nil), newest: dates&.fetch("newest", nil)}
303
+
304
+ when "memory://recent"
305
+ rows = db.execute("SELECT id, subject, content, created_at FROM memories WHERE cart_id = ? ORDER BY created_at DESC LIMIT 10", [cart[:id]])
306
+ {memories: rows.map { |r| {id: r["id"], subject: r["subject"], content: r["content"], created_at: r["created_at"]} }}
307
+
308
+ else
309
+ {error: "Unknown resource: #{uri}"}
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "db"
5
+ require_relative "embedding"
6
+ require_relative "cart"
7
+
8
+ module Personality
9
+ class Memory
10
+ attr_reader :cart_id
11
+
12
+ def initialize(cart_id: nil)
13
+ @cart_id = cart_id || Cart.active[:id]
14
+ end
15
+
16
+ def store(subject:, content:, metadata: {})
17
+ db = DB.connection
18
+ embedding = Embedding.generate(content)
19
+
20
+ db.execute(
21
+ "INSERT INTO memories (cart_id, subject, content, metadata) VALUES (?, ?, ?, ?)",
22
+ [cart_id, subject, content, JSON.generate(metadata)]
23
+ )
24
+ memory_id = db.last_insert_row_id
25
+
26
+ unless embedding.empty?
27
+ db.execute(
28
+ "INSERT INTO vec_memories (memory_id, embedding) VALUES (?, ?)",
29
+ [memory_id, embedding.to_json]
30
+ )
31
+ end
32
+
33
+ {id: memory_id, subject: subject}
34
+ end
35
+
36
+ def recall(query:, limit: 5, subject: nil)
37
+ embedding = Embedding.generate(query)
38
+ return {memories: []} if embedding.empty?
39
+
40
+ db = DB.connection
41
+
42
+ if subject
43
+ rows = db.execute(<<~SQL, [embedding.to_json, limit, cart_id, subject])
44
+ SELECT m.id, m.subject, m.content, m.metadata, m.created_at, v.distance
45
+ FROM vec_memories v
46
+ INNER JOIN memories m ON m.id = v.memory_id
47
+ WHERE v.embedding MATCH ? AND k = ?
48
+ AND m.cart_id = ? AND m.subject = ?
49
+ ORDER BY v.distance
50
+ SQL
51
+ else
52
+ rows = db.execute(<<~SQL, [embedding.to_json, limit, cart_id])
53
+ SELECT m.id, m.subject, m.content, m.metadata, m.created_at, v.distance
54
+ FROM vec_memories v
55
+ INNER JOIN memories m ON m.id = v.memory_id
56
+ WHERE v.embedding MATCH ? AND k = ?
57
+ AND m.cart_id = ?
58
+ ORDER BY v.distance
59
+ SQL
60
+ end
61
+
62
+ memories = rows.map { |r| memory_row_to_hash(r) }
63
+ {memories: memories}
64
+ end
65
+
66
+ def search(subject: nil, limit: 20)
67
+ db = DB.connection
68
+
69
+ if subject
70
+ rows = db.execute(
71
+ "SELECT id, subject, content, created_at FROM memories WHERE cart_id = ? AND subject LIKE ? ORDER BY created_at DESC LIMIT ?",
72
+ [cart_id, "%#{subject}%", limit]
73
+ )
74
+ else
75
+ rows = db.execute(
76
+ "SELECT id, subject, content, created_at FROM memories WHERE cart_id = ? ORDER BY created_at DESC LIMIT ?",
77
+ [cart_id, limit]
78
+ )
79
+ end
80
+
81
+ memories = rows.map do |r|
82
+ {id: r["id"], subject: r["subject"], content: r["content"], created_at: r["created_at"]}
83
+ end
84
+ {memories: memories}
85
+ end
86
+
87
+ def forget(id:)
88
+ db = DB.connection
89
+ db.execute("DELETE FROM vec_memories WHERE memory_id = ?", [id])
90
+ db.execute("DELETE FROM memories WHERE id = ? AND cart_id = ?", [id, cart_id])
91
+ deleted = db.changes > 0
92
+ {deleted: deleted}
93
+ end
94
+
95
+ def list
96
+ db = DB.connection
97
+ rows = db.execute(
98
+ "SELECT subject, COUNT(*) AS count FROM memories WHERE cart_id = ? GROUP BY subject ORDER BY count DESC",
99
+ [cart_id]
100
+ )
101
+ subjects = rows.map { |r| {subject: r["subject"], count: r["count"]} }
102
+ {subjects: subjects}
103
+ end
104
+
105
+ private
106
+
107
+ def memory_row_to_hash(row)
108
+ {
109
+ id: row["id"],
110
+ subject: row["subject"],
111
+ content: row["content"],
112
+ metadata: safe_parse_json(row["metadata"]),
113
+ created_at: row["created_at"],
114
+ distance: row["distance"]
115
+ }
116
+ end
117
+
118
+ def safe_parse_json(str)
119
+ return {} if str.nil? || str.empty?
120
+ JSON.parse(str)
121
+ rescue JSON::ParserError
122
+ {}
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+
6
+ module Personality
7
+ module TTS
8
+ VOICES_DIR = File.join(Dir.home, ".local", "share", "psn", "voices")
9
+ DATA_DIR = File.join(Dir.home, ".local", "share", "personality", "data")
10
+ DEFAULT_VOICE = "en_US-lessac-medium"
11
+
12
+ PID_FILE = File.join(DATA_DIR, "tts.pid")
13
+ WAV_FILE = File.join(DATA_DIR, "tts_current.wav")
14
+ NATURAL_STOP_FLAG = File.join(DATA_DIR, "tts_natural_stop")
15
+
16
+ PIPER_VOICES_BASE_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main"
17
+
18
+ class << self
19
+ # --- Synthesis & Playback ---
20
+
21
+ def speak(text, voice: nil)
22
+ stop_current
23
+ voice ||= active_voice
24
+
25
+ model_path = find_voice(voice)
26
+ return {error: "Voice not found: #{voice}"} unless model_path
27
+
28
+ piper_bin = find_piper
29
+ return {error: "piper not installed"} unless piper_bin
30
+
31
+ FileUtils.mkdir_p(DATA_DIR)
32
+
33
+ # Synthesize to WAV
34
+ stdout, stderr, status = Open3.capture3(
35
+ piper_bin, "--model", model_path, "--output_file", WAV_FILE,
36
+ stdin_data: text
37
+ )
38
+ return {error: "piper failed: #{stderr}"} unless status.success?
39
+
40
+ # Play audio (macOS: afplay, Linux: aplay)
41
+ player = player_command
42
+ return {error: "No audio player found"} unless player
43
+
44
+ pid = spawn(player, WAV_FILE, [:out, :err] => "/dev/null")
45
+ save_pid(pid)
46
+
47
+ {speaking: true, voice: voice, pid: pid}
48
+ end
49
+
50
+ def speak_and_wait(text, voice: nil)
51
+ result = speak(text, voice: voice)
52
+ return result if result[:error]
53
+
54
+ Process.wait(result[:pid])
55
+ clear_pid
56
+ result.merge(speaking: false)
57
+ rescue Errno::ECHILD
58
+ clear_pid
59
+ result
60
+ end
61
+
62
+ def stop_current
63
+ return false unless File.exist?(PID_FILE)
64
+
65
+ pid = File.read(PID_FILE).strip.to_i
66
+ Process.kill("TERM", pid)
67
+ clear_pid
68
+ true
69
+ rescue Errno::ESRCH, Errno::EPERM
70
+ clear_pid
71
+ false
72
+ end
73
+
74
+ # --- Interrupt Protocol ---
75
+
76
+ def mark_natural_stop
77
+ FileUtils.mkdir_p(DATA_DIR)
78
+ FileUtils.touch(NATURAL_STOP_FLAG)
79
+ end
80
+
81
+ def interrupt_check
82
+ if File.exist?(NATURAL_STOP_FLAG)
83
+ File.delete(NATURAL_STOP_FLAG)
84
+ {action: :continue, reason: "natural_stop"}
85
+ else
86
+ stopped = stop_current
87
+ {action: :stopped, reason: "user_interrupt", was_playing: stopped}
88
+ end
89
+ end
90
+
91
+ def clear_natural_stop_flag
92
+ File.delete(NATURAL_STOP_FLAG) if File.exist?(NATURAL_STOP_FLAG)
93
+ end
94
+
95
+ # --- Voice Management ---
96
+
97
+ def find_voice(name)
98
+ path = File.join(VOICES_DIR, "#{name}.onnx")
99
+ File.exist?(path) ? path : nil
100
+ end
101
+
102
+ def list_voices
103
+ return [] unless Dir.exist?(VOICES_DIR)
104
+
105
+ Dir.glob(File.join(VOICES_DIR, "*.onnx")).map do |path|
106
+ name = File.basename(path, ".onnx")
107
+ size_mb = File.size(path) / (1024.0 * 1024)
108
+ {name: name, path: path, size_mb: size_mb.round(1)}
109
+ end.sort_by { |v| v[:name].downcase }
110
+ end
111
+
112
+ def download_voice(voice_name)
113
+ FileUtils.mkdir_p(VOICES_DIR)
114
+
115
+ model_path = File.join(VOICES_DIR, "#{voice_name}.onnx")
116
+ config_path = File.join(VOICES_DIR, "#{voice_name}.onnx.json")
117
+
118
+ return {exists: true, voice: voice_name} if File.exist?(model_path)
119
+
120
+ parts = voice_name.split("-")
121
+ return {error: "Invalid voice format"} if parts.length < 2
122
+
123
+ lang = parts[0] # en_US
124
+ lang_short = lang.split("_")[0] # en
125
+ name = parts[1] # lessac
126
+ quality = parts[2] || "medium"
127
+
128
+ model_url = "#{PIPER_VOICES_BASE_URL}/#{lang_short}/#{lang}/#{name}/#{quality}/#{voice_name}.onnx"
129
+ config_url = "#{PIPER_VOICES_BASE_URL}/#{lang_short}/#{lang}/#{name}/#{quality}/#{voice_name}.onnx.json"
130
+
131
+ require "net/http"
132
+ require "uri"
133
+
134
+ download_file(model_url, model_path)
135
+ download_file(config_url, config_path)
136
+
137
+ size_mb = File.size(model_path) / (1024.0 * 1024)
138
+ {installed: true, voice: voice_name, size_mb: size_mb.round(1)}
139
+ rescue => e
140
+ FileUtils.rm_f(model_path)
141
+ FileUtils.rm_f(config_path)
142
+ {error: "Download failed: #{e.message}"}
143
+ end
144
+
145
+ def active_voice
146
+ ENV.fetch("PERSONALITY_VOICE", DEFAULT_VOICE)
147
+ end
148
+
149
+ private
150
+
151
+ def find_piper
152
+ # Check common locations
153
+ [
154
+ File.join(Dir.home, ".local", "bin", "piper"),
155
+ `which piper 2>/dev/null`.strip
156
+ ].find { |p| !p.empty? && File.executable?(p) }
157
+ end
158
+
159
+ def player_command
160
+ if RUBY_PLATFORM.include?("darwin")
161
+ "afplay"
162
+ elsif system("which aplay > /dev/null 2>&1")
163
+ "aplay"
164
+ end
165
+ end
166
+
167
+ def save_pid(pid)
168
+ File.write(PID_FILE, pid.to_s)
169
+ end
170
+
171
+ def clear_pid
172
+ FileUtils.rm_f(PID_FILE)
173
+ FileUtils.rm_f(WAV_FILE)
174
+ end
175
+
176
+ def download_file(url, dest)
177
+ uri = URI.parse(url)
178
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
179
+ request = Net::HTTP::Get.new(uri)
180
+ http.request(request) do |response|
181
+ raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
182
+
183
+ File.open(dest, "wb") do |file|
184
+ response.read_body { |chunk| file.write(chunk) }
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Personality
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Personality
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require_relative "personality/version"
8
+ require_relative "personality/db"
9
+ require_relative "personality/embedding"
10
+ require_relative "personality/chunker"
11
+ require_relative "personality/hooks"
12
+ require_relative "personality/context"
13
+ require_relative "personality/cart"
14
+ require_relative "personality/memory"
15
+ require_relative "personality/tts"
16
+ require_relative "personality/indexer"
17
+ require_relative "personality/cli"