personality 0.1.4 → 0.1.6

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: 52727e75770170ffc4ba9f49315e446bf4a0a51f7539e6028649321431d7e8b6
4
+ data.tar.gz: 727ec7aa33ec4a865efdc1bfa8d192e01c6680af5c1810a94e8207e6784e633e
5
5
  SHA512:
6
- metadata.gz: a206366eac85dd5cf06b4e3ceb439357864f7f3d93e4eeadf4fda22e651712014b0d15c87f1b611faff95a8b8c4c31c2b662a095b7f17f925c34091fa5888699
7
- data.tar.gz: 7f69b0935390d8e2164473510faca1612550a2fd4d4fc6955fcb7eb26a4ebc9b2c469aa930bef0e0b25e6a82f9fb43d43f75c69cbe1b8c7c90a7e4347408572d
6
+ metadata.gz: 52ec7cadad303c775da827d00064a7d932d8e374251927f3b8686d0da30ce1f9d2e713f50d5af5de0298dfb6b0a66c96e56d09bbd2c9f11e588c0ff939da603b
7
+ data.tar.gz: cf0e4dfea669a3de87bba632c9b47f28794c93b81131070be3a94113e7eb97d5d249e33ccf2f7d578bf83fe8df42e74be24bf60e80193130a4fab5bc4b7edf99
@@ -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/LICENSE ADDED
@@ -0,0 +1,80 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Adam Ladachowski / Saiden
6
+ Licensed Work: personality
7
+ The Licensed Work is (c) 2026 Adam Ladachowski
8
+
9
+ This License applies to each version of the Licensed
10
+ Work. A "version" means any release identified by a
11
+ git tag (e.g., v1.0.0). All commits between version
12
+ tags are considered part of the preceding tagged
13
+ version. Commits before the first tag are version 0.0.0.
14
+
15
+ Additional Use Grant: You may make production use of the Licensed Work,
16
+ provided Your use does not include offering the
17
+ Licensed Work to third parties on a hosted or
18
+ embedded basis in a manner that competes with
19
+ Saiden's paid products or services.
20
+
21
+ For organizations with annual revenue exceeding
22
+ $1,000,000 USD, commercial licensing is required.
23
+ Contact: licensing@saiden.dev
24
+
25
+ Change Date: Four years from the date of each version's release.
26
+ A version's release date is the timestamp of its git
27
+ tag. For version 0.0.0, the Change Date is 2030-04-01.
28
+
29
+ Change License: MIT
30
+
31
+ Notice
32
+
33
+ The Business Source License (this document, or the "License") is not an
34
+ Open Source license. However, the Licensed Work will eventually be made
35
+ available under an Open Source License, as stated in this License.
36
+
37
+ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
38
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
39
+
40
+ Terms
41
+
42
+ The Licensor hereby grants you the right to copy, modify, create derivative
43
+ works, redistribute, and make non-production use of the Licensed Work. The
44
+ Licensor may make an Additional Use Grant, above, permitting limited
45
+ production use.
46
+
47
+ Effective on the Change Date, or the fourth anniversary of the first publicly
48
+ available distribution of a specific version of the Licensed Work under this
49
+ License, whichever comes first, the Licensor hereby grants you rights under
50
+ the terms of the Change License, and the rights granted in the paragraph
51
+ above terminate.
52
+
53
+ If your use of the Licensed Work does not comply with the requirements
54
+ currently in effect as described in this License, you must purchase a
55
+ commercial license from the Licensor, its affiliated entities, or authorized
56
+ resellers, or you must refrain from using the Licensed Work.
57
+
58
+ All copies of the original and modified Licensed Work, and derivative works
59
+ of the Licensed Work, are subject to this License. This License applies
60
+ separately for each version of the Licensed Work and the Change Date may vary
61
+ for each version of the Licensed Work released by Licensor.
62
+
63
+ You must conspicuously display this License on each original or modified copy
64
+ of the Licensed Work. If you receive the Licensed Work in original or
65
+ modified form from a third party, the terms and conditions set forth in this
66
+ License apply to your use of that work.
67
+
68
+ Any use of the Licensed Work in violation of this License will automatically
69
+ terminate your rights under this License for the current and all other
70
+ versions of the Licensed Work.
71
+
72
+ This License does not grant you any right in any trademark or logo of
73
+ Licensor or its affiliates (provided that you may use a trademark or logo of
74
+ Licensor as expressly required by this License).
75
+
76
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
77
+ AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
78
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
79
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
80
+ TITLE.
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
 
@@ -78,28 +78,11 @@ module Personality
78
78
  Personality::Hooks.log("PreCompact", data)
79
79
  end
80
80
 
81
- desc "notification", "Notification hook — log and speak via TTS"
81
+ desc "notification", "Notification hook — log only"
82
82
  def notification
83
83
  require_relative "../hooks"
84
- require_relative "../tts"
85
-
86
84
  data = Personality::Hooks.read_stdin_json
87
85
  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
86
  end
104
87
 
105
88
  desc "install", "Generate hooks.json for Claude Code"
@@ -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