personality 0.1.4 → 0.1.5

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: 972d9bf070fc2adb6ff1c9f74e848d149f187009da323ff57e05db830deaa7ae
4
- data.tar.gz: 032e91ed47c22b7ce7bfd80e9aeea49e24ca2b725519bd753642d99d15e257b0
3
+ metadata.gz: aade948f54c7dc05e1cbbbe4b080c0ab2f512e7754f0c105083da6d5c2cb8fe2
4
+ data.tar.gz: 4dd893cc5fe35fc7c7922d656d67b3c4c1ebcb5871909886bcca9afc32e53f57
5
5
  SHA512:
6
- metadata.gz: a206366eac85dd5cf06b4e3ceb439357864f7f3d93e4eeadf4fda22e651712014b0d15c87f1b611faff95a8b8c4c31c2b662a095b7f17f925c34091fa5888699
7
- data.tar.gz: 7f69b0935390d8e2164473510faca1612550a2fd4d4fc6955fcb7eb26a4ebc9b2c469aa930bef0e0b25e6a82f9fb43d43f75c69cbe1b8c7c90a7e4347408572d
6
+ metadata.gz: 63345439767b800d1acbd2244faaa15aa55165ad85d16c5ef113ef55e39ff08b10eb6e95a9fa167dc61b24c7b29d5e20a233547b7cbacb02b1068b4d08fa21f1
7
+ data.tar.gz: 2c2e7c5935347f285cd515e01ffcfee234f51c8f7dcca326354b12e1ce69ef92be77f45f1d3733534fcf3ee69d6a407c67a984be53333b1de8e985cd3302ec70
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "mcp__plugin_psn_core__index_clear",
5
+ "mcp__plugin_psn_core__index_code",
6
+ "mcp__plugin_psn_core__index_search"
7
+ ]
8
+ }
9
+ }
data/README.md CHANGED
@@ -1,35 +1,114 @@
1
1
  # Personality
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ Infrastructure layer for [Claude Code](https://docs.anthropic.com/en/docs/claude-code): persistent memory with vector search, code/doc indexing, TTS, persona management, and MCP server.
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/personality`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ [![Gem Version](https://badge.fury.io/rb/personality.svg)](https://rubygems.org/gems/personality)
6
+ [![CI](https://github.com/aladac/personality/actions/workflows/main.yml/badge.svg)](https://github.com/aladac/personality/actions/workflows/main.yml)
7
+
8
+ ## Features
9
+
10
+ - **Memory** — Cart-scoped persistent memory with vector similarity search
11
+ - **Code/Doc Indexing** — Semantic search across codebases and documentation
12
+ - **TTS** — Text-to-speech via piper-tts with voice management
13
+ - **Personas** — Cartridge-based persona system with identity, preferences, and memories
14
+ - **MCP Server** — 18 tools and 3 resources over stdio transport
6
15
 
7
16
  ## Installation
8
17
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
18
+ ```bash
19
+ gem install personality
20
+ ```
21
+
22
+ Or add to your Gemfile:
23
+
24
+ ```ruby
25
+ gem "personality"
26
+ ```
27
+
28
+ ### Dependencies
10
29
 
11
- Install the gem and add to the application's Gemfile by executing:
30
+ | Dependency | Purpose |
31
+ |------------|---------|
32
+ | [Ollama](https://ollama.com) | Embeddings (nomic-embed-text) |
33
+ | [piper-tts](https://github.com/rhasspy/piper) | Text-to-speech synthesis |
34
+ | SQLite | Database (bundled via sqlite3 gem) |
35
+
36
+ ## Usage
37
+
38
+ ### CLI
12
39
 
13
40
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
41
+ psn help # Show all commands
42
+ psn memory store SUBJECT CONTENT # Store a memory
43
+ psn memory recall QUERY # Recall by similarity
44
+ psn index code ./src # Index code for search
45
+ psn index search "auth handler" # Semantic code search
46
+ psn tts speak "Hello world" # Speak text aloud
47
+ psn cart list # List personas
15
48
  ```
16
49
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
50
+ ### MCP Server
51
+
52
+ Start the MCP server for Claude Code integration:
18
53
 
19
54
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
55
+ psn-mcp
21
56
  ```
22
57
 
23
- ## Usage
58
+ Tools use dot notation: `memory.store`, `memory.recall`, `index.search`, `cart.use`, etc.
59
+
60
+ ### As a Claude Code Plugin
61
+
62
+ Add to your Claude Code `settings.json`:
24
63
 
25
- TODO: Write usage instructions here
64
+ ```json
65
+ {
66
+ "plugins": ["personality"]
67
+ }
68
+ ```
69
+
70
+ ## Architecture
71
+
72
+ Service objects hold all logic. CLI and MCP are thin wrappers.
73
+
74
+ ```
75
+ lib/personality/
76
+ db.rb # SQLite + sqlite-vec, migrations
77
+ embedding.rb # Ollama HTTP client (nomic-embed-text, 768 dims)
78
+ chunker.rb # Text splitting (2000 chars, 200 overlap)
79
+ memory.rb # Vector memory (cart-scoped)
80
+ indexer.rb # Code/doc indexing + semantic search
81
+ cart.rb # Persona management
82
+ tts.rb # Piper TTS synthesis + playback
83
+ mcp/server.rb # MCP server (official mcp gem)
84
+ ```
26
85
 
27
86
  ## Development
28
87
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
88
+ ```bash
89
+ bundle install
90
+ bundle exec rake # Run tests + linter
91
+ bundle exec rspec # Tests only
92
+ bundle exec standardrb # Linter only
93
+ ```
94
+
95
+ Tests stub external dependencies (Ollama, piper) — no services needed to run the suite.
96
+
97
+ ## Releasing
98
+
99
+ Push a stable version tag to trigger the release workflow:
100
+
101
+ ```bash
102
+ # Update lib/personality/version.rb, then:
103
+ git commit -am "Bump version to X.Y.Z"
104
+ git tag vX.Y.Z
105
+ git push && git push origin vX.Y.Z
106
+ ```
107
+
108
+ This publishes to [RubyGems](https://rubygems.org/gems/personality), [GitHub Packages](https://github.com/aladac/personality/packages), and creates a [GitHub Release](https://github.com/aladac/personality/releases) with the `.gem` attached.
30
109
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
110
+ Pre-release versions (e.g. `v0.2.0.pre1`) are not published.
32
111
 
33
- ## Contributing
112
+ ## License
34
113
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/personality.
114
+ [MIT](LICENSE.txt)
data/exe/psn-http ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "personality"
5
+ require "personality/mcp/rack_app"
6
+ require "personality/mcp/oauth"
7
+ require "rack"
8
+ require "rack/handler/puma"
9
+
10
+ host = ENV.fetch("PSN_HOST", "127.0.0.1")
11
+ port = ENV.fetch("PSN_PORT", "8081").to_i
12
+ base_url = ENV.fetch("PSN_BASE_URL", "https://psn.saiden.dev")
13
+
14
+ # OAuth credentials
15
+ client_id = Personality::MCP::OAuth::CLIENT_ID
16
+ client_secret = Personality::MCP::OAuth::CLIENT_SECRET
17
+
18
+ app = Personality::MCP::RackApp.new(base_url: base_url)
19
+
20
+ puts "Starting PSN MCP HTTP server on #{host}:#{port}"
21
+ puts "Base URL: #{base_url}"
22
+ puts ""
23
+ puts "OAuth Credentials (for claude.ai):"
24
+ puts " Client ID: #{client_id}"
25
+ puts " Client Secret: #{client_secret}"
26
+ puts ""
27
+
28
+ Rack::Handler::Puma.run(app, Host: host, Port: port, Verbose: true)
data/exe/psn-mcp CHANGED
@@ -4,4 +4,18 @@
4
4
  require "personality"
5
5
  require "personality/mcp/server"
6
6
 
7
- Personality::MCP::Server.run
7
+ # Parse --mode argument
8
+ mode = :all
9
+ ARGV.each_with_index do |arg, i|
10
+ if arg == "--mode" && ARGV[i + 1]
11
+ mode = ARGV[i + 1].to_sym
12
+ elsif arg.start_with?("--mode=")
13
+ mode = arg.split("=", 2).last.to_sym
14
+ elsif arg == "--indexer-only"
15
+ mode = :indexer
16
+ elsif arg == "--core-only"
17
+ mode = :core
18
+ end
19
+ end
20
+
21
+ Personality::MCP::Server.run(mode: mode)
data/exe/psn-voice ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "personality"
5
+ require "personality/mcp/voice_server"
6
+
7
+ Personality::MCP::VoiceServer.run
@@ -5,6 +5,7 @@ require_relative "db"
5
5
  module Personality
6
6
  class Cart
7
7
  DEFAULT_TAG = "default"
8
+ CONFIG_PATH = File.join(Dir.home, ".config", "psn", "config.toml")
8
9
 
9
10
  class << self
10
11
  def find_or_create(tag)
@@ -23,7 +24,7 @@ module Personality
23
24
  end
24
25
 
25
26
  def active
26
- tag = ENV.fetch("PERSONALITY_CART", DEFAULT_TAG)
27
+ tag = ENV.fetch("PERSONALITY_CART") { configured_cart }
27
28
  find_or_create(tag)
28
29
  end
29
30
 
@@ -57,6 +58,16 @@ module Personality
57
58
 
58
59
  private
59
60
 
61
+ def configured_cart
62
+ return DEFAULT_TAG unless File.exist?(CONFIG_PATH)
63
+
64
+ require "toml-rb"
65
+ config = TomlRB.parse(File.read(CONFIG_PATH))
66
+ config.dig("persona", "cart") || DEFAULT_TAG
67
+ rescue => _e
68
+ DEFAULT_TAG
69
+ end
70
+
60
71
  def row_to_hash(row)
61
72
  {
62
73
  id: row["id"],
@@ -225,7 +225,7 @@ module Personality
225
225
  [cart.version, cart.preferences.identity.source, cart.preferences.identity.name, cart.preferences.identity.type, cart.preferences.identity.tagline, db_cart[:id]]
226
226
  )
227
227
 
228
- mem = Memory.new
228
+ mem = Memory.new(cart_id: db_cart[:id])
229
229
  stored = 0
230
230
  skipped = 0
231
231
 
@@ -11,17 +11,35 @@ module Personality
11
11
  require_relative "../indexer"
12
12
  require_relative "../db"
13
13
  require "pastel"
14
- require "tty-spinner"
14
+ require "tty-progressbar"
15
15
 
16
16
  DB.migrate!
17
17
  pastel = Pastel.new
18
- spinner = TTY::Spinner.new(" :spinner Indexing code...", format: :dots)
19
- spinner.auto_spin
20
-
21
- result = Personality::Indexer.new.index_code(path: path, project: options[:project])
18
+ bar = nil
19
+
20
+ result = Personality::Indexer.new.index_code(path: path, project: options[:project]) do |file, current, total|
21
+ if bar.nil?
22
+ bar = TTY::ProgressBar.new(
23
+ " Indexing [:bar] :current/:total :percent",
24
+ total: total,
25
+ width: 30,
26
+ complete: "█",
27
+ incomplete: "░",
28
+ hide_cursor: false,
29
+ clear: false
30
+ )
31
+ end
32
+ bar.current = current
33
+ # Show current file on line below progress bar, then cursor back up
34
+ filename = File.basename(file)
35
+ $stdout.print "\n #{pastel.dim(filename.slice(0, 60).ljust(60))}\e[K\e[A\r"
36
+ $stdout.flush
37
+ end
22
38
 
23
- spinner.success(pastel.green("done"))
24
- puts " #{pastel.bold(result[:project])}: #{result[:indexed]} chunks indexed"
39
+ # Clear filename line (move down, clear, move up) and finish
40
+ $stdout.print "\n\e[K\e[A"
41
+ bar&.finish
42
+ puts " #{pastel.bold(result[:project])}: #{pastel.green(result[:indexed])} chunks indexed"
25
43
  if result[:errors].any?
26
44
  puts pastel.yellow(" Errors (#{result[:errors].length}):")
27
45
  result[:errors].each { |e| puts " #{e}" }
@@ -34,17 +52,35 @@ module Personality
34
52
  require_relative "../indexer"
35
53
  require_relative "../db"
36
54
  require "pastel"
37
- require "tty-spinner"
55
+ require "tty-progressbar"
38
56
 
39
57
  DB.migrate!
40
58
  pastel = Pastel.new
41
- spinner = TTY::Spinner.new(" :spinner Indexing docs...", format: :dots)
42
- spinner.auto_spin
43
-
44
- result = Personality::Indexer.new.index_docs(path: path, project: options[:project])
59
+ bar = nil
60
+
61
+ result = Personality::Indexer.new.index_docs(path: path, project: options[:project]) do |file, current, total|
62
+ if bar.nil?
63
+ bar = TTY::ProgressBar.new(
64
+ " Indexing [:bar] :current/:total :percent",
65
+ total: total,
66
+ width: 30,
67
+ complete: "█",
68
+ incomplete: "░",
69
+ hide_cursor: false,
70
+ clear: false
71
+ )
72
+ end
73
+ bar.current = current
74
+ # Show current file on line below progress bar, then cursor back up
75
+ filename = File.basename(file)
76
+ $stdout.print "\n #{pastel.dim(filename.slice(0, 60).ljust(60))}\e[K\e[A\r"
77
+ $stdout.flush
78
+ end
45
79
 
46
- spinner.success(pastel.green("done"))
47
- puts " #{pastel.bold(result[:project])}: #{result[:indexed]} chunks indexed"
80
+ # Clear filename line (move down, clear, move up) and finish
81
+ $stdout.print "\n\e[K\e[A"
82
+ bar&.finish
83
+ puts " #{pastel.bold(result[:project])}: #{pastel.green(result[:indexed])} chunks indexed"
48
84
  if result[:errors].any?
49
85
  puts pastel.yellow(" Errors (#{result[:errors].length}):")
50
86
  result[:errors].each { |e| puts " #{e}" }
@@ -7,11 +7,12 @@ module Personality
7
7
  class Tts < Thor
8
8
  desc "speak TEXT", "Speak text aloud"
9
9
  option :voice, type: :string, aliases: "-v", desc: "Voice model name"
10
+ option :language, type: :string, aliases: "-l", desc: "Language code (en, pl)"
10
11
  def speak(text)
11
12
  require_relative "../tts"
12
13
  require "pastel"
13
14
 
14
- result = Personality::TTS.speak_and_wait(text, voice: options[:voice])
15
+ result = Personality::TTS.speak_and_wait(text, voice: options[:voice], language: options[:language])
15
16
  if result[:error]
16
17
  puts Pastel.new.red(result[:error])
17
18
  exit 1
@@ -124,14 +125,38 @@ module Personality
124
125
 
125
126
  pastel = Pastel.new
126
127
  voice = Personality::TTS.active_voice
128
+ backend = Personality::TTS.backend
129
+
130
+ puts "#{pastel.bold("Backend:")} #{backend}"
127
131
  puts "#{pastel.bold("Voice:")} #{voice}"
128
132
  if Personality::TTS.find_voice(voice)
129
- puts "#{pastel.green("✓")} Installed"
133
+ puts "#{pastel.green("✓")} Available"
134
+ elsif backend == "xtts"
135
+ puts "#{pastel.yellow("!")} Voice not found on XTTS host"
130
136
  else
131
137
  puts "#{pastel.yellow("!")} Not installed — run: psn tts download #{voice}"
132
138
  end
133
139
  end
134
140
 
141
+ desc "backend", "Show TTS backend info"
142
+ def backend
143
+ require_relative "../tts"
144
+ require "pastel"
145
+
146
+ pastel = Pastel.new
147
+ backend = Personality::TTS.backend
148
+
149
+ puts "#{pastel.bold("Backend:")} #{backend}"
150
+ if backend == "xtts"
151
+ puts "#{pastel.bold("Host:")} #{Personality::TTS::XTTS_HOST}"
152
+ puts "#{pastel.bold("Project:")} #{Personality::TTS::XTTS_PROJECT}"
153
+ puts pastel.dim("\nSet TTS_BACKEND=piper to use local piper")
154
+ else
155
+ puts "#{pastel.bold("Voices dir:")} #{Personality::TTS::VOICES_DIR}"
156
+ puts pastel.dim("\nSet TTS_BACKEND=xtts to use XTTS on junkpile")
157
+ end
158
+ end
159
+
135
160
  def self.exit_on_failure?
136
161
  true
137
162
  end
@@ -9,8 +9,17 @@ module Personality
9
9
  class Indexer
10
10
  CODE_EXTENSIONS = %w[.py .rs .rb .js .ts .go .java .c .cpp .h].to_set.freeze
11
11
  DOC_EXTENSIONS = %w[.md .txt .rst .adoc].to_set.freeze
12
-
13
- def index_code(path:, project: nil, extensions: nil)
12
+ EXCLUDED_DIRS = %w[
13
+ target dist build out _build deps
14
+ node_modules vendor .bundle
15
+ __pycache__ .venv venv env .env
16
+ .git .hg .svn
17
+ coverage .nyc_output
18
+ .next .turbo .cargo .cache
19
+ tmp log logs
20
+ ].to_set.freeze
21
+
22
+ def index_code(path:, project: nil, extensions: nil, &block)
14
23
  dir = File.expand_path(path)
15
24
  proj = project || File.basename(dir)
16
25
  exts = if extensions
@@ -19,14 +28,14 @@ module Personality
19
28
  CODE_EXTENSIONS
20
29
  end
21
30
 
22
- index_files(dir, proj, exts, table: "code_chunks", vec_table: "vec_code", language: true)
31
+ index_files(dir, proj, exts, table: "code_chunks", vec_table: "vec_code", language: true, &block)
23
32
  end
24
33
 
25
- def index_docs(path:, project: nil)
34
+ def index_docs(path:, project: nil, &block)
26
35
  dir = File.expand_path(path)
27
36
  proj = project || File.basename(dir)
28
37
 
29
- index_files(dir, proj, DOC_EXTENSIONS, table: "doc_chunks", vec_table: "vec_docs", language: false)
38
+ index_files(dir, proj, DOC_EXTENSIONS, table: "doc_chunks", vec_table: "vec_docs", language: false, &block)
30
39
  end
31
40
 
32
41
  def search(query:, type: :all, project: nil, limit: 10)
@@ -91,13 +100,22 @@ module Personality
91
100
 
92
101
  private
93
102
 
94
- def index_files(dir, project, extensions, table:, vec_table:, language:)
103
+ def index_files(dir, project, extensions, table:, vec_table:, language:, &block)
95
104
  indexed = 0
96
105
  errors = []
97
106
 
98
- Dir.glob(File.join(dir, "**", "*")).each do |file_path|
99
- next unless File.file?(file_path)
100
- next unless extensions.include?(File.extname(file_path).downcase)
107
+ # Collect files first for progress reporting, excluding build/vendor dirs
108
+ files = Dir.glob(File.join(dir, "**", "*")).select do |file_path|
109
+ next false unless File.file?(file_path)
110
+ next false unless extensions.include?(File.extname(file_path).downcase)
111
+ # Skip files in excluded directories
112
+ path_parts = file_path.sub("#{dir}/", "").split("/")
113
+ !path_parts.any? { |part| EXCLUDED_DIRS.include?(part) }
114
+ end
115
+
116
+ total = files.size
117
+ files.each_with_index do |file_path, idx|
118
+ yield(file_path, idx + 1, total) if block_given?
101
119
 
102
120
  begin
103
121
  lang = language ? File.extname(file_path).downcase[1..] : nil