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.
data/TODO.md ADDED
@@ -0,0 +1,65 @@
1
+ # TODO
2
+
3
+ ## Phase 1: Foundation
4
+
5
+ - [x] `db.rb` — singleton connection, sqlite-vec loading, migration runner
6
+ - [x] Schema v2 — carts, memories, code_chunks, doc_chunks, vec0 virtual tables
7
+ - [x] Update `init.rb` to use `db.rb` for schema creation (remove inline SQL)
8
+ - [x] `embedding.rb` — Ollama HTTP client, `generate(text)`, 8000 char truncation
9
+ - [x] `chunker.rb` — overlapping window splitter (2000/200)
10
+ - [x] Tests for db, embedding, chunker
11
+
12
+ ## Phase 2: Hooks & Context
13
+
14
+ - [x] `hooks.rb` service — JSONL logging, field truncation, config via `logging.toml`
15
+ - [x] `cli/hooks.rb` — all 9 hook event subcommands (pre-tool-use, post-tool-use, stop, subagent-stop, session-start, session-end, user-prompt-submit, pre-compact, notification)
16
+ - [x] `psn hooks install` — generate `hooks.json` for Claude Code settings
17
+ - [x] `context.rb` service — session file-read tracking (`/tmp/psn-context/`)
18
+ - [x] `cli/context.rb` — track-read, check, list, clear subcommands
19
+ - [x] Tests for hooks and context
20
+
21
+ ## Phase 3: Cart & Memory
22
+
23
+ - [x] `cart.rb` service — find_or_create, active, list, use, create
24
+ - [x] `cli/cart.rb` — list, use, create subcommands
25
+ - [x] `memory.rb` service — store, recall, search, forget, list (cart-scoped)
26
+ - [x] `memory.rb` save hook — extract learnings from transcript (stub endpoint, ready for future impl)
27
+ - [ ] `memory.rb` precompact hook — deduplicate memories (>0.95 similarity) [deferred: needs real usage data]
28
+ - [x] `cli/memory.rb` — store, recall, search, forget, list, save subcommands
29
+ - [x] Tests for cart and memory
30
+
31
+ ## Phase 4: TTS
32
+
33
+ - [x] `tts.rb` service — piper synthesis, playback, PID tracking, voice resolution
34
+ - [x] TTS interrupt protocol — natural stop flag, interrupt-check logic
35
+ - [x] `cli/tts.rb` — speak, stop, mark-natural-stop, interrupt-check, voices, download, test, current
36
+ - [x] Voice download from HuggingFace (piper-voices repo)
37
+ - [x] Tests for TTS service
38
+
39
+ ## Phase 5: Indexer
40
+
41
+ - [x] `indexer.rb` service — index_code, index_docs, search, status, clear, index_single_file
42
+ - [x] `indexer.rb` hook — re-index on Write/Edit (PostToolUse)
43
+ - [x] `cli/index.rb` — code, docs, search, status, clear, hook subcommands
44
+ - [x] Tests for indexer
45
+
46
+ ## Phase 6: MCP Server
47
+
48
+ - [x] `mcp/server.rb` — official `mcp` gem (0.9.1), stdio transport, define_tool API
49
+ - [x] Memory tools — memory.store, memory.recall, memory.search, memory.forget, memory.list
50
+ - [x] Index tools — index.code, index.docs, index.search, index.status, index.clear
51
+ - [x] Cart tools — cart.list, cart.use, cart.create
52
+ - [x] MCP resources — memory://subjects, memory://stats, memory://recent
53
+ - [x] `exe/psn-mcp` — standalone MCP binary
54
+ - [ ] `.mcp.json` template generation [deferred: trivial, can add to hooks install]
55
+ - [x] Tests for MCP server (15 specs)
56
+
57
+ ## Phase 7: Integration & Polish
58
+
59
+ - [x] `psn hooks session-start` — load persona instructions + intro prompt
60
+ - [x] `psn hooks notification` — speak notifications via TTS
61
+ - [x] End-to-end test: init → store memory → recall → verify
62
+ - [x] End-to-end test: index code → search → verify results
63
+ - [x] End-to-end test: MCP server protocol → tools/call → resources/read
64
+ - [ ] CLI help text and `--help` output review
65
+ - [ ] README update with usage examples
@@ -0,0 +1,193 @@
1
+ ---
2
+ source: https://github.com/modelcontextprotocol/ruby-sdk
3
+ fetched: 2026-03-26
4
+ gem: mcp (0.9.1)
5
+ ---
6
+
7
+ # MCP Ruby SDK
8
+
9
+ The official Ruby SDK for Model Context Protocol servers and clients.
10
+
11
+ ## Building an MCP Server
12
+
13
+ The `MCP::Server` class handles JSON-RPC requests and responses implementing the MCP specification.
14
+
15
+ ### Key Features
16
+
17
+ - JSON-RPC 2.0 message handling
18
+ - Protocol initialization and capability negotiation
19
+ - Tool registration and invocation
20
+ - Prompt registration and execution
21
+ - Resource registration and retrieval
22
+ - Stdio & Streamable HTTP transports
23
+ - Notifications for list changes (tools, prompts, resources)
24
+
25
+ ### Supported Methods
26
+
27
+ - `initialize` - Protocol init, returns server capabilities
28
+ - `ping` - Health check
29
+ - `tools/list` - Lists registered tools and schemas
30
+ - `tools/call` - Invokes a tool with arguments
31
+ - `prompts/list` - Lists registered prompts
32
+ - `prompts/get` - Retrieves a prompt by name
33
+ - `resources/list` - Lists registered resources
34
+ - `resources/read` - Retrieves a resource by URI
35
+ - `resources/templates/list` - Lists resource templates
36
+
37
+ ## Defining Tools
38
+
39
+ Three ways to define tools:
40
+
41
+ ### 1. Class definition
42
+
43
+ ```ruby
44
+ class MyTool < MCP::Tool
45
+ tool_name "my_tool"
46
+ description "Does something"
47
+ input_schema(
48
+ properties: {
49
+ message: { type: "string" },
50
+ },
51
+ required: ["message"]
52
+ )
53
+
54
+ def self.call(message:, server_context:)
55
+ MCP::Tool::Response.new([{ type: "text", text: "OK" }])
56
+ end
57
+ end
58
+ ```
59
+
60
+ ### 2. Tool.define
61
+
62
+ ```ruby
63
+ tool = MCP::Tool.define(
64
+ name: "my_tool",
65
+ description: "Does something",
66
+ ) do |args, server_context:|
67
+ MCP::Tool::Response.new([{ type: "text", text: "OK" }])
68
+ end
69
+ ```
70
+
71
+ ### 3. Server#define_tool
72
+
73
+ ```ruby
74
+ server.define_tool(
75
+ name: "my_tool",
76
+ description: "Does something",
77
+ input_schema: {
78
+ type: "object",
79
+ properties: { msg: { type: "string" } },
80
+ required: ["msg"]
81
+ }
82
+ ) do |msg:, server_context:|
83
+ MCP::Tool::Response.new([{ type: "text", text: msg }])
84
+ end
85
+ ```
86
+
87
+ **Important:** When using `define_tool`, arguments are passed as **keyword args** (splatted from the arguments hash). The `server_context:` keyword is always passed.
88
+
89
+ ### Tool Names
90
+
91
+ Tool names only allow: `A-Z`, `a-z`, `0-9`, `_`, `-`, `.`
92
+
93
+ **No `/` allowed.** Use dots for namespacing: `memory.store`, `index.search`.
94
+
95
+ ### Tool Responses
96
+
97
+ Tools must return `MCP::Tool::Response`:
98
+
99
+ ```ruby
100
+ MCP::Tool::Response.new([{ type: "text", text: "result" }])
101
+ MCP::Tool::Response.new([{ type: "text", text: "error" }], error: true)
102
+ ```
103
+
104
+ ## Resources
105
+
106
+ Register resources with the server:
107
+
108
+ ```ruby
109
+ resource = MCP::Resource.new(
110
+ uri: "memory://subjects",
111
+ name: "memory-subjects",
112
+ description: "All subjects",
113
+ mime_type: "application/json",
114
+ )
115
+
116
+ server = MCP::Server.new(name: "my_server", resources: [resource])
117
+ # or: server.resources = [resource]
118
+ ```
119
+
120
+ Handle reads:
121
+
122
+ ```ruby
123
+ server.resources_read_handler do |params|
124
+ [{
125
+ uri: params[:uri],
126
+ mimeType: "application/json",
127
+ text: JSON.generate({ data: "value" })
128
+ }]
129
+ end
130
+ ```
131
+
132
+ ## Server Handle API
133
+
134
+ Two methods for processing requests:
135
+
136
+ ```ruby
137
+ # Hash in, Hash out (symbol keys)
138
+ response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
139
+ # => { jsonrpc: "2.0", id: 1, result: { tools: [...] } }
140
+
141
+ # JSON string in, JSON string out
142
+ response = server.handle_json('{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')
143
+ # => '{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}'
144
+ ```
145
+
146
+ **Must call `initialize` before other methods** — the server requires protocol handshake first.
147
+
148
+ ## Stdio Transport
149
+
150
+ ```ruby
151
+ server = MCP::Server.new(name: "my_server", version: "1.0")
152
+ # ... define tools, resources ...
153
+
154
+ transport = MCP::Transports::StdioTransport.new(server)
155
+ transport.open # Blocks, reads stdin, writes stdout
156
+ ```
157
+
158
+ The transport class is at `MCP::Server::Transports::StdioTransport` (require `mcp/transports/stdio`).
159
+
160
+ ## Client (for connecting to MCP servers)
161
+
162
+ ```ruby
163
+ stdio_transport = MCP::Client::Stdio.new(
164
+ command: "bundle",
165
+ args: ["exec", "ruby", "path/to/server.rb"],
166
+ env: { "API_KEY" => "secret" },
167
+ read_timeout: 30
168
+ )
169
+ client = MCP::Client.new(transport: stdio_transport)
170
+
171
+ tools = client.tools
172
+ response = client.call_tool(tool: tools.first, arguments: { message: "Hello" })
173
+ stdio_transport.close
174
+ ```
175
+
176
+ ## Notifications
177
+
178
+ ```ruby
179
+ server.notify_tools_list_changed
180
+ server.notify_resources_list_changed
181
+ server.notify_log_message(data: { message: "Hello" }, level: "info")
182
+ server.notify_progress(progress_token: "token", progress: 50, total: 100)
183
+ ```
184
+
185
+ ## Configuration
186
+
187
+ ```ruby
188
+ # Set server context (passed to tool blocks)
189
+ server.server_context = { user_id: 123 }
190
+
191
+ # Protocol version
192
+ server.configuration.protocol_version # "2024-11-05"
193
+ ```
data/exe/psn ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "personality"
5
+
6
+ Personality::CLI.start(ARGV)
data/exe/psn-mcp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "personality"
5
+ require "personality/mcp/server"
6
+
7
+ Personality::MCP::Server.run
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "db"
4
+
5
+ module Personality
6
+ class Cart
7
+ DEFAULT_TAG = "default"
8
+
9
+ class << self
10
+ def find_or_create(tag)
11
+ db = DB.connection
12
+ row = db.execute("SELECT * FROM carts WHERE tag = ?", [tag]).first
13
+
14
+ if row
15
+ row_to_hash(row)
16
+ else
17
+ db.execute(
18
+ "INSERT INTO carts (tag) VALUES (?)", [tag]
19
+ )
20
+ id = db.last_insert_row_id
21
+ {id: id, tag: tag}
22
+ end
23
+ end
24
+
25
+ def active
26
+ tag = ENV.fetch("PERSONALITY_CART", DEFAULT_TAG)
27
+ find_or_create(tag)
28
+ end
29
+
30
+ def list
31
+ db = DB.connection
32
+ db.execute("SELECT * FROM carts ORDER BY tag").map { |row| row_to_hash(row) }
33
+ end
34
+
35
+ def use(tag)
36
+ find_or_create(tag)
37
+ end
38
+
39
+ def create(tag, name: nil, type: nil, tagline: nil)
40
+ db = DB.connection
41
+ existing = db.execute("SELECT id FROM carts WHERE tag = ?", [tag]).first
42
+ return find_or_create(tag) if existing
43
+
44
+ db.execute(
45
+ "INSERT INTO carts (tag, name, type, tagline) VALUES (?, ?, ?, ?)",
46
+ [tag, name, type, tagline]
47
+ )
48
+ id = db.last_insert_row_id
49
+ {id: id, tag: tag, name: name, type: type, tagline: tagline}
50
+ end
51
+
52
+ def find(tag)
53
+ db = DB.connection
54
+ row = db.execute("SELECT * FROM carts WHERE tag = ?", [tag]).first
55
+ row ? row_to_hash(row) : nil
56
+ end
57
+
58
+ private
59
+
60
+ def row_to_hash(row)
61
+ {
62
+ id: row["id"],
63
+ tag: row["tag"],
64
+ version: row["version"],
65
+ name: row["name"],
66
+ type: row["type"],
67
+ tagline: row["tagline"],
68
+ source: row["source"],
69
+ created_at: row["created_at"],
70
+ updated_at: row["updated_at"]
71
+ }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Personality
4
+ module Chunker
5
+ MIN_LENGTH = 10
6
+ DEFAULT_SIZE = 2000
7
+ DEFAULT_OVERLAP = 200
8
+
9
+ class << self
10
+ def split(text, size: DEFAULT_SIZE, overlap: DEFAULT_OVERLAP)
11
+ return [] if text.nil? || text.length < MIN_LENGTH
12
+
13
+ return [text] if text.length <= size
14
+
15
+ chunks = []
16
+ start = 0
17
+ while start < text.length
18
+ chunk = text[start, size]
19
+ chunks << chunk
20
+ start += size - overlap
21
+ end
22
+
23
+ chunks
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Personality
6
+ class CLI < Thor
7
+ class Cart < Thor
8
+ desc "list", "List all personas"
9
+ def list
10
+ require_relative "../cart"
11
+ require_relative "../db"
12
+ require "pastel"
13
+ require "tty-table"
14
+
15
+ DB.migrate!
16
+ pastel = Pastel.new
17
+ carts = Personality::Cart.list
18
+
19
+ if carts.empty?
20
+ puts pastel.dim("No personas found")
21
+ return
22
+ end
23
+
24
+ table = TTY::Table.new(
25
+ header: %w[ID Tag Name Type],
26
+ rows: carts.map { |c| [c[:id], c[:tag], c[:name] || "-", c[:type] || "-"] }
27
+ )
28
+ puts table.render(:unicode, padding: [0, 1])
29
+ end
30
+
31
+ desc "use TAG", "Switch active persona"
32
+ def use(tag)
33
+ require_relative "../cart"
34
+ require_relative "../db"
35
+ require "pastel"
36
+
37
+ DB.migrate!
38
+ cart = Personality::Cart.use(tag)
39
+ puts "#{Pastel.new.green("Active:")} #{cart[:tag]} (id: #{cart[:id]})"
40
+ end
41
+
42
+ desc "create TAG", "Create a new persona"
43
+ option :name, type: :string, desc: "Display name"
44
+ option :type, type: :string, desc: "Persona type"
45
+ option :tagline, type: :string, desc: "Short description"
46
+ def create(tag)
47
+ require_relative "../cart"
48
+ require_relative "../db"
49
+ require "pastel"
50
+
51
+ DB.migrate!
52
+ cart = Personality::Cart.create(tag, name: options[:name], type: options[:type], tagline: options[:tagline])
53
+ puts "#{Pastel.new.green("Created:")} #{cart[:tag]} (id: #{cart[:id]})"
54
+ end
55
+
56
+ def self.exit_on_failure?
57
+ true
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Personality
6
+ class CLI < Thor
7
+ class Context < Thor
8
+ desc "track-read", "Track a file read (PostToolUse hook, reads JSON from stdin)"
9
+ def track_read
10
+ require_relative "../context"
11
+ require_relative "../hooks"
12
+
13
+ data = Personality::Hooks.read_stdin_json
14
+ return unless data
15
+
16
+ file_path = data.dig("tool_input", "file_path")
17
+ return unless file_path
18
+
19
+ session_id = data["session_id"]
20
+ Personality::Context.track_read(file_path, session_id: session_id)
21
+ end
22
+
23
+ desc "check FILE", "Check if a file is in session context"
24
+ def check(file_path)
25
+ require_relative "../context"
26
+ require "pastel"
27
+
28
+ pastel = Pastel.new
29
+ if Personality::Context.check(file_path)
30
+ puts "#{pastel.green("✓")} #{file_path} is in context"
31
+ else
32
+ puts "#{pastel.dim("✗")} #{file_path} not in context"
33
+ exit 1
34
+ end
35
+ end
36
+
37
+ desc "list", "List all files in current session context"
38
+ def list
39
+ require_relative "../context"
40
+ require "pastel"
41
+
42
+ pastel = Pastel.new
43
+ files = Personality::Context.list
44
+
45
+ if files.empty?
46
+ puts pastel.dim("No files in context")
47
+ else
48
+ puts "#{pastel.bold("Files in context")} (#{files.length})"
49
+ files.each { |f| puts " #{f}" }
50
+ end
51
+ end
52
+
53
+ desc "clear", "Clear session context"
54
+ def clear
55
+ require_relative "../context"
56
+ require "pastel"
57
+
58
+ Personality::Context.clear
59
+ puts Pastel.new.green("Context cleared")
60
+ end
61
+
62
+ def self.exit_on_failure?
63
+ true
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Personality
6
+ class CLI < Thor
7
+ class Hooks < Thor
8
+ desc "pre-tool-use", "PreToolUse hook — log and allow"
9
+ def pre_tool_use
10
+ require_relative "../hooks"
11
+ data = Personality::Hooks.read_stdin_json
12
+ Personality::Hooks.log("PreToolUse", data)
13
+ end
14
+
15
+ desc "post-tool-use", "PostToolUse hook — log"
16
+ def post_tool_use
17
+ require_relative "../hooks"
18
+ data = Personality::Hooks.read_stdin_json
19
+ Personality::Hooks.log("PostToolUse", data)
20
+ end
21
+
22
+ desc "stop", "Stop hook — log"
23
+ def stop
24
+ require_relative "../hooks"
25
+ data = Personality::Hooks.read_stdin_json
26
+ Personality::Hooks.log("Stop", data)
27
+ end
28
+
29
+ desc "subagent-stop", "SubagentStop hook — log"
30
+ def subagent_stop
31
+ require_relative "../hooks"
32
+ data = Personality::Hooks.read_stdin_json
33
+ Personality::Hooks.log("SubagentStop", data)
34
+ end
35
+
36
+ desc "session-start", "SessionStart hook — log, load persona, output intro"
37
+ def session_start
38
+ require_relative "../hooks"
39
+ require_relative "../cart"
40
+ require_relative "../db"
41
+
42
+ data = Personality::Hooks.read_stdin_json
43
+ Personality::Hooks.log("SessionStart", data)
44
+
45
+ begin
46
+ Personality::DB.migrate!
47
+ cart = Personality::Cart.active
48
+
49
+ if cart[:name] || cart[:tagline]
50
+ name = cart[:name] || cart[:tag]
51
+ puts "**Active Persona:** #{name}"
52
+ puts cart[:tagline] if cart[:tagline]
53
+ puts
54
+ end
55
+ rescue
56
+ # Silently continue if cart loading fails
57
+ end
58
+ end
59
+
60
+ desc "session-end", "SessionEnd hook — log"
61
+ def session_end
62
+ require_relative "../hooks"
63
+ data = Personality::Hooks.read_stdin_json
64
+ Personality::Hooks.log("SessionEnd", data)
65
+ end
66
+
67
+ desc "user-prompt-submit", "UserPromptSubmit hook — log and allow"
68
+ def user_prompt_submit
69
+ require_relative "../hooks"
70
+ data = Personality::Hooks.read_stdin_json
71
+ Personality::Hooks.log("UserPromptSubmit", data)
72
+ end
73
+
74
+ desc "pre-compact", "PreCompact hook — log"
75
+ def pre_compact
76
+ require_relative "../hooks"
77
+ data = Personality::Hooks.read_stdin_json
78
+ Personality::Hooks.log("PreCompact", data)
79
+ end
80
+
81
+ desc "notification", "Notification hook — log and speak via TTS"
82
+ def notification
83
+ require_relative "../hooks"
84
+ require_relative "../tts"
85
+
86
+ data = Personality::Hooks.read_stdin_json
87
+ Personality::Hooks.log("Notification", data)
88
+
89
+ return unless data
90
+
91
+ message = data["message"]
92
+ return if message.nil? || message.empty?
93
+
94
+ # Prepend project name for context
95
+ cwd = data["cwd"] || Dir.pwd
96
+ project = File.basename(cwd)
97
+ speech = "#{project}: #{message}"
98
+
99
+ Personality::TTS.stop_current
100
+ Personality::TTS.speak(speech)
101
+ rescue
102
+ # Silently continue if TTS fails
103
+ end
104
+
105
+ desc "install", "Generate hooks.json for Claude Code"
106
+ option :output, type: :string, aliases: "-o", default: "hooks.json",
107
+ desc: "Output file path"
108
+ def install
109
+ require_relative "../hooks"
110
+ output = options[:output]
111
+ File.write(output, Personality::Hooks.generate_hooks_json)
112
+ puts "Generated #{output}"
113
+ end
114
+
115
+ def self.exit_on_failure?
116
+ true
117
+ end
118
+ end
119
+ end
120
+ end