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.
- checksums.yaml +7 -0
- data/CLAUDE.md +88 -0
- data/PLAN.md +621 -0
- data/README.md +35 -0
- data/Rakefile +10 -0
- data/TODO.md +65 -0
- data/docs/mcp-ruby-sdk.md +193 -0
- data/exe/psn +6 -0
- data/exe/psn-mcp +7 -0
- data/lib/personality/cart.rb +75 -0
- data/lib/personality/chunker.rb +27 -0
- data/lib/personality/cli/cart.rb +61 -0
- data/lib/personality/cli/context.rb +67 -0
- data/lib/personality/cli/hooks.rb +120 -0
- data/lib/personality/cli/index.rb +147 -0
- data/lib/personality/cli/memory.rb +130 -0
- data/lib/personality/cli/tts.rb +140 -0
- data/lib/personality/cli.rb +54 -0
- data/lib/personality/context.rb +73 -0
- data/lib/personality/db.rb +148 -0
- data/lib/personality/embedding.rb +44 -0
- data/lib/personality/hooks.rb +143 -0
- data/lib/personality/indexer.rb +211 -0
- data/lib/personality/init.rb +257 -0
- data/lib/personality/mcp/server.rb +314 -0
- data/lib/personality/memory.rb +125 -0
- data/lib/personality/tts.rb +191 -0
- data/lib/personality/version.rb +5 -0
- data/lib/personality.rb +17 -0
- metadata +269 -0
|
@@ -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
|
data/lib/personality.rb
ADDED
|
@@ -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"
|