tina4ruby 3.13.61 → 3.13.62
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 +4 -4
- data/lib/tina4/context/chunker.rb +145 -0
- data/lib/tina4/context.rb +406 -0
- data/lib/tina4/dev_admin.rb +13 -0
- data/lib/tina4/mcp.rb +22 -0
- data/lib/tina4/migration.rb +7 -5
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2654a6c8f7c322aeb7b6719ff4e908840e4dec23afa49dc02e165a0b0c74b0c7
|
|
4
|
+
data.tar.gz: a5b0f4e3813c0597f7d6734ba5472a4641f70af228c8ad9513166519743f0e0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fc83c37a2afdb997a9faf495d1fb6547b287b968ae40a93c94c404142f4e783e2cb569796ee2072a09e11d356ec53336b1d5858ed8d2e029a7562a8d8fec255
|
|
7
|
+
data.tar.gz: bd2a127e45f88513764af879ad80a5abe166001661cb0809b9af5d00e9ce9d950513a8afaef85ddf562d2d0a48945e068e4f236ac3a494893ab4cdedbc705c0c
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 Ruby
|
|
4
|
+
#
|
|
5
|
+
# Text folding + chunking for the Context subsystem.
|
|
6
|
+
#
|
|
7
|
+
# A thin, idiomatic port of the proven slice of tina4-python's
|
|
8
|
+
# tina4_python/context/chunker.py (itself a port of neemee's
|
|
9
|
+
# tokenizer/chunkers). Pure stdlib — nothing here touches SQLite; it produces
|
|
10
|
+
# the (folded body, raw text) pairs the FTS5 index stores.
|
|
11
|
+
|
|
12
|
+
module Tina4
|
|
13
|
+
class Context
|
|
14
|
+
# Folding + chunking helpers. All methods are module functions so they read
|
|
15
|
+
# as `Chunker.fold(text)`, matching the Python free-function layout.
|
|
16
|
+
module Chunker
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Join comma-grouped numbers ("24,601" -> "24601") and split camelCase
|
|
20
|
+
# ("ForeignKeyField" -> "foreign key field") so a query token reaches a
|
|
21
|
+
# code identifier. snake_case already splits for free at the tokenizer.
|
|
22
|
+
NUM_COMMA = /(?<=\d),(?=\d)/
|
|
23
|
+
CAMEL = /(?<=[a-z0-9])(?=[A-Z])/
|
|
24
|
+
WORD_RE = /[a-z0-9]+/
|
|
25
|
+
|
|
26
|
+
# Sentence boundary = end punctuation FOLLOWED BY whitespace/EOL, or a
|
|
27
|
+
# newline. NOT a bare '.', which would shred embedded code in prose
|
|
28
|
+
# ("db.fetch()" -> "db. fetch()"). Intra-token dots stay intact.
|
|
29
|
+
SENT = /(?<=[.!?])\s+|\n+/
|
|
30
|
+
|
|
31
|
+
# Chunk boundaries across languages. Ruby is the native target so
|
|
32
|
+
# def/class/module are accepted at any indent (Ruby nests methods inside
|
|
33
|
+
# classes/modules); the cross-language alternatives (php/js/ts function,
|
|
34
|
+
# ts export, Object Pascal unit/routine headers) mirror the Python port so
|
|
35
|
+
# the SAME index can hold .rb, .py, .php, .js, .ts, .pas … files.
|
|
36
|
+
TOPLEVEL = Regexp.new(
|
|
37
|
+
'\A(?:' \
|
|
38
|
+
'\s*(?:async )?def |\s*class |\s*module |@\w' \
|
|
39
|
+
'|\s*(?:public |private |protected |static |final |abstract )*function \w' \
|
|
40
|
+
'|(?:export )?(?:default )?(?:abstract )?class ' \
|
|
41
|
+
'|interface |trait ' \
|
|
42
|
+
'|export (?:const|function|interface|type|async) ' \
|
|
43
|
+
'|[Uu]nit |[Pp]rocedure |[Ff]unction |[Cc]onstructor |[Dd]estructor ' \
|
|
44
|
+
'|[Tt]ype$' \
|
|
45
|
+
')'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Lowercase, strip diacritics, join comma-grouped numbers, split camelCase.
|
|
49
|
+
# Applied symmetrically to the indexed body and to query tokens so matching
|
|
50
|
+
# is consistent: a query for "field" reaches "IntegerField".
|
|
51
|
+
def fold(text)
|
|
52
|
+
s = text.to_s.gsub(NUM_COMMA, "")
|
|
53
|
+
s = s.gsub(CAMEL, " ").downcase
|
|
54
|
+
s.unicode_normalize(:nfkd)
|
|
55
|
+
.encode(Encoding::ASCII, invalid: :replace, undef: :replace, replace: "")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fold simple plurals: strip one trailing 's', never from ss/us/is endings
|
|
59
|
+
# (class, status, axis). QUERY-side only — so "fields" also reaches "Field".
|
|
60
|
+
def light_stem(token)
|
|
61
|
+
if token.length > 3 && token.end_with?("s") && !token.end_with?("ss", "us", "is")
|
|
62
|
+
token[0..-2]
|
|
63
|
+
else
|
|
64
|
+
token
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Accent-folded lowercase alphanumeric tokens (digits kept).
|
|
69
|
+
def terms(text)
|
|
70
|
+
fold(text).scan(WORD_RE)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Pack line-segments into chunks of at most max_lines lines, hard-splitting
|
|
74
|
+
# any single oversized segment.
|
|
75
|
+
def pack(segments, max_lines)
|
|
76
|
+
chunks = []
|
|
77
|
+
cur = []
|
|
78
|
+
segments.each do |seg|
|
|
79
|
+
seg = seg.dup
|
|
80
|
+
while seg.length > max_lines # oversized segment: hard split
|
|
81
|
+
unless cur.empty?
|
|
82
|
+
chunks << cur
|
|
83
|
+
cur = []
|
|
84
|
+
end
|
|
85
|
+
chunks << seg[0...max_lines]
|
|
86
|
+
seg = seg[max_lines..] || []
|
|
87
|
+
end
|
|
88
|
+
if !cur.empty? && cur.length + seg.length > max_lines
|
|
89
|
+
chunks << cur
|
|
90
|
+
cur = []
|
|
91
|
+
end
|
|
92
|
+
cur += seg
|
|
93
|
+
end
|
|
94
|
+
chunks << cur unless cur.empty?
|
|
95
|
+
chunks
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Chunk source on top-level def/class/module boundaries (sentence chunking
|
|
99
|
+
# shreds code). Segments are packed up to max_lines, and every chunk starts
|
|
100
|
+
# with a "# file: <path>" line so the path's tokens are indexed — "where is
|
|
101
|
+
# the router?" should match core/router.rb by name.
|
|
102
|
+
#
|
|
103
|
+
# Returns an array of [index, chunk_text] pairs.
|
|
104
|
+
def chunk_code(text, path: "", max_lines: 60)
|
|
105
|
+
lines = text.to_s.split("\n", -1)
|
|
106
|
+
lines.pop if lines.last == "" # drop trailing empty from a final newline
|
|
107
|
+
bounds = []
|
|
108
|
+
lines.each_with_index { |ln, i| bounds << i if TOPLEVEL.match?(ln) }
|
|
109
|
+
bounds = [0] + bounds if bounds.empty? || bounds.first != 0
|
|
110
|
+
|
|
111
|
+
ends = bounds[1..] + [lines.length]
|
|
112
|
+
segments = bounds.zip(ends).map { |a, b| lines[a...b] }
|
|
113
|
+
|
|
114
|
+
header = path.to_s.empty? ? nil : "# file: #{path}"
|
|
115
|
+
out = []
|
|
116
|
+
pack(segments, max_lines).each_with_index do |ch, i|
|
|
117
|
+
body = ((header ? [header] : []) + ch).join("\n")
|
|
118
|
+
out << [i, body]
|
|
119
|
+
end
|
|
120
|
+
out
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Chunk prose/docs into sentence-packed windows of at most max_words words.
|
|
124
|
+
# Returns an array of [index, chunk_text] pairs.
|
|
125
|
+
def chunk_text(text, max_words: 350)
|
|
126
|
+
sents = text.to_s.split(SENT).map(&:strip).reject(&:empty?)
|
|
127
|
+
chunks = []
|
|
128
|
+
cur = []
|
|
129
|
+
cur_words = 0
|
|
130
|
+
sents.each do |s|
|
|
131
|
+
w = s.split.length
|
|
132
|
+
if !cur.empty? && cur_words + w > max_words
|
|
133
|
+
chunks << cur.join(" ")
|
|
134
|
+
cur = []
|
|
135
|
+
cur_words = 0
|
|
136
|
+
end
|
|
137
|
+
cur << s
|
|
138
|
+
cur_words += w
|
|
139
|
+
end
|
|
140
|
+
chunks << cur.join(" ") unless cur.empty?
|
|
141
|
+
chunks.each_with_index.map { |c, i| [i, c] }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 Ruby
|
|
4
|
+
#
|
|
5
|
+
# Context — a native, zero-dependency code/doc grounding index.
|
|
6
|
+
#
|
|
7
|
+
# Lets a Tina4 app ground its own AI assistant on its own source, offline: it
|
|
8
|
+
# walks the project, chunks code on def/class/module boundaries and docs as
|
|
9
|
+
# prose, and answers keyword/fuzzy queries over a SQLite FTS5 index. The
|
|
10
|
+
# sqlite3 gem is already a Tina4-Ruby runtime dependency and modern SQLite ships
|
|
11
|
+
# FTS5 + bm25() built in, so this adds NO new dependency.
|
|
12
|
+
#
|
|
13
|
+
# The retrieval core is a thin port of tina4-python's tina4_python/context
|
|
14
|
+
# (itself the proven slice of neemee: SqliteFTS + the stable
|
|
15
|
+
# source-over-tests / definition-first reorderings). It COMPLEMENTS the api_*
|
|
16
|
+
# reflection tools: api_* is exact structural lookup, Context is fuzzy/semantic
|
|
17
|
+
# FTS over source + docs.
|
|
18
|
+
#
|
|
19
|
+
# ctx = Tina4::Context.new(".tina4/context.db")
|
|
20
|
+
# ctx.index_root("src")
|
|
21
|
+
# ctx.search("where is the auth token issued?", k: 5)
|
|
22
|
+
# # -> [{ path: "auth.rb", score: 2.31, snippet: "..." }, ...]
|
|
23
|
+
#
|
|
24
|
+
# On-disk index defaults to ".tina4/context.db" (gitignored). Guards a sqlite
|
|
25
|
+
# build without FTS5: if absent, the Context degrades to safe no-ops rather than
|
|
26
|
+
# crashing the app.
|
|
27
|
+
|
|
28
|
+
require "pathname"
|
|
29
|
+
require "fileutils"
|
|
30
|
+
require "set"
|
|
31
|
+
require_relative "context/chunker"
|
|
32
|
+
|
|
33
|
+
module Tina4
|
|
34
|
+
class Context
|
|
35
|
+
# File classification — mirrors the Python port's repo walk.
|
|
36
|
+
CODE_EXTS = %w[.py .php .js .mjs .ts .rb .pas .dpr .dpk .inc .dfm .fmx].freeze
|
|
37
|
+
DOC_EXTS = %w[.md .txt .rst .twig .html].freeze
|
|
38
|
+
# deploy/CLI/env answers live in config files, not sources — chunk as code
|
|
39
|
+
# (line windows), since sentence chunking shreds YAML/Dockerfiles.
|
|
40
|
+
CONFIG_EXTS = %w[.toml .yml .yaml].freeze
|
|
41
|
+
SPECIAL_FILES = %w[dockerfile makefile docker-compose.yml package.json
|
|
42
|
+
composer.json gemfile .env.example .env.sample].freeze
|
|
43
|
+
# Same dirs the Python port skips, plus Tina4 runtime dirs that hold no
|
|
44
|
+
# source of truth (our own index/backups, session blobs, logs).
|
|
45
|
+
SKIP_DIRS = %w[.git __pycache__ node_modules vendor dist build coverage
|
|
46
|
+
.idea .venv venv .pytest_cache .tina4 sessions logs].freeze
|
|
47
|
+
|
|
48
|
+
# Generic question/code vocabulary that never NAMES a symbol — kept small.
|
|
49
|
+
DEF_STOP = %w[the and what how does can which where when who why list all
|
|
50
|
+
available module class function functions method methods def
|
|
51
|
+
get set new return import from with that this are is was for
|
|
52
|
+
into use used].to_set.freeze
|
|
53
|
+
|
|
54
|
+
# A chunk that DEFINES a queried symbol ("def get_token", "class Widget",
|
|
55
|
+
# "module Auth") should out-rank one that merely USES it. Matched against
|
|
56
|
+
# the folded body.
|
|
57
|
+
DEF_KW = '(?:async def|def|class|module|function|fn|func|interface|trait)'
|
|
58
|
+
|
|
59
|
+
attr_reader :path, :available, :root
|
|
60
|
+
|
|
61
|
+
# `path` is the on-disk index file (its parent dir is created). `fts5_check`
|
|
62
|
+
# overrides FTS5 detection (used by tests to exercise the graceful-
|
|
63
|
+
# degradation path); defaults to a real probe of this build.
|
|
64
|
+
def initialize(path = "./.tina4/context.db", fts5_check: nil)
|
|
65
|
+
@path = path.to_s
|
|
66
|
+
@lock = Mutex.new
|
|
67
|
+
@conn = nil
|
|
68
|
+
@root = nil # set by index_root; reindex_file relabels against it
|
|
69
|
+
check = fts5_check || -> { self.class.fts5_available? }
|
|
70
|
+
@available = !!check.call
|
|
71
|
+
return unless @available
|
|
72
|
+
|
|
73
|
+
require "sqlite3"
|
|
74
|
+
parent = File.dirname(@path)
|
|
75
|
+
FileUtils.mkdir_p(parent) unless parent.empty? || parent == "." || File.directory?(parent)
|
|
76
|
+
# One connection guarded by a Mutex — the dev-MCP reload hook may run on a
|
|
77
|
+
# Puma worker thread, so access is serialized rather than same-thread-only.
|
|
78
|
+
@conn = SQLite3::Database.new(@path)
|
|
79
|
+
ensure_table
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── FTS5 availability ───────────────────────────────────────
|
|
83
|
+
# Whether this Ruby's sqlite3 build supports FTS5 (a real in-memory probe).
|
|
84
|
+
def self.fts5_available?
|
|
85
|
+
require "sqlite3"
|
|
86
|
+
db = SQLite3::Database.new(":memory:")
|
|
87
|
+
begin
|
|
88
|
+
db.execute("CREATE VIRTUAL TABLE _probe USING fts5(x)")
|
|
89
|
+
true
|
|
90
|
+
rescue SQLite3::Exception
|
|
91
|
+
false
|
|
92
|
+
ensure
|
|
93
|
+
db.close
|
|
94
|
+
end
|
|
95
|
+
rescue LoadError, StandardError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ── schema ──────────────────────────────────────────────────
|
|
100
|
+
def ensure_table
|
|
101
|
+
# `body` holds fold(text) so query/index tokenization is symmetric; `raw`
|
|
102
|
+
# (UNINDEXED) keeps the original text to return as a snippet; `path`/`cid`
|
|
103
|
+
# (UNINDEXED) are metadata used for upsert + citation.
|
|
104
|
+
@conn.execute(
|
|
105
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS chunks " \
|
|
106
|
+
"USING fts5(cid UNINDEXED, path UNINDEXED, raw UNINDEXED, body)"
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
private :ensure_table
|
|
110
|
+
|
|
111
|
+
# Drop and recreate the index (full rebuild starting point).
|
|
112
|
+
def reset
|
|
113
|
+
return unless @available
|
|
114
|
+
|
|
115
|
+
@lock.synchronize do
|
|
116
|
+
@conn.execute("DROP TABLE IF EXISTS chunks")
|
|
117
|
+
ensure_table
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ── indexing ────────────────────────────────────────────────
|
|
122
|
+
# [index, chunk_text] pairs — code/config files on def/class boundaries,
|
|
123
|
+
# docs as prose.
|
|
124
|
+
def self.chunks_for(label, text)
|
|
125
|
+
ext = File.extname(label).downcase
|
|
126
|
+
special = SPECIAL_FILES.include?(File.basename(label).downcase)
|
|
127
|
+
if CODE_EXTS.include?(ext) || CONFIG_EXTS.include?(ext) || special
|
|
128
|
+
Chunker.chunk_code(text, path: label)
|
|
129
|
+
else
|
|
130
|
+
Chunker.chunk_text(text)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# UPSERT one file into the index: delete this path's existing chunks,
|
|
135
|
+
# re-chunk the current contents, insert. A single file save re-indexes just
|
|
136
|
+
# that file. `label` is the stored/citation path (defaults to `file`) and
|
|
137
|
+
# MUST be stable across calls for the same file so the delete targets the
|
|
138
|
+
# right rows. Returns rows inserted.
|
|
139
|
+
def index_path(file, label: nil)
|
|
140
|
+
return 0 unless @available
|
|
141
|
+
|
|
142
|
+
stored = as_text(label.nil? ? file : label)
|
|
143
|
+
begin
|
|
144
|
+
text = File.read(file, encoding: "UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
return 0
|
|
147
|
+
end
|
|
148
|
+
rows = self.class.chunks_for(stored, text).map do |i, chunk|
|
|
149
|
+
["#{stored}:#{i}", stored, chunk, Chunker.fold(chunk)]
|
|
150
|
+
end
|
|
151
|
+
@lock.synchronize do
|
|
152
|
+
@conn.execute("DELETE FROM chunks WHERE path = ?", [stored])
|
|
153
|
+
unless rows.empty?
|
|
154
|
+
stmt = @conn.prepare("INSERT INTO chunks(cid, path, raw, body) VALUES (?, ?, ?, ?)")
|
|
155
|
+
begin
|
|
156
|
+
rows.each { |r| stmt.execute(r) }
|
|
157
|
+
ensure
|
|
158
|
+
stmt.close
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
rows.length
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# True if a file should be indexed (the per-file filter used by both
|
|
166
|
+
# index_root and reindex_file). Directory skipping is handled separately.
|
|
167
|
+
def self.eligible?(filename)
|
|
168
|
+
fn = filename.downcase
|
|
169
|
+
return false if fn.end_with?(".min.js")
|
|
170
|
+
|
|
171
|
+
ext = File.extname(fn)
|
|
172
|
+
CODE_EXTS.include?(ext) || DOC_EXTS.include?(ext) ||
|
|
173
|
+
CONFIG_EXTS.include?(ext) || SPECIAL_FILES.include?(fn)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Walk `root`, indexing every eligible file (skips vendor/build/runtime
|
|
177
|
+
# dirs). Paths are stored RELATIVE to `root` for clean citations. Records
|
|
178
|
+
# `root` so reindex_file can relabel a changed file consistently. Returns
|
|
179
|
+
# the total number of chunks inserted.
|
|
180
|
+
def index_root(root)
|
|
181
|
+
return 0 unless @available
|
|
182
|
+
|
|
183
|
+
root = canonical_dir(root)
|
|
184
|
+
@root = root
|
|
185
|
+
total = 0
|
|
186
|
+
walk(root) do |full|
|
|
187
|
+
rel = Pathname.new(full).relative_path_from(Pathname.new(root)).to_s
|
|
188
|
+
total += index_path(full, label: rel)
|
|
189
|
+
end
|
|
190
|
+
total
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Re-index a single changed file into the LIVE index — the hook the dev
|
|
194
|
+
# reload trigger (POST /__dev/api/reload) calls so code_search tracks edits
|
|
195
|
+
# without a rebuild. Resolves `changed_path` against the indexed root, then:
|
|
196
|
+
# outside root / under a skip-or-dot dir / ineligible -> skip (-1);
|
|
197
|
+
# deleted -> drop its chunks (0); otherwise UPSERT (rows). No-op (-1) until
|
|
198
|
+
# index_root has run (nothing to keep fresh yet).
|
|
199
|
+
def reindex_file(changed_path)
|
|
200
|
+
return -1 unless @available && @root
|
|
201
|
+
|
|
202
|
+
p = changed_path.to_s
|
|
203
|
+
# The reload trigger reports paths relative to the project root (cwd during
|
|
204
|
+
# `tina4 serve`); the index root may be a subdir like src/.
|
|
205
|
+
p = File.join(Dir.pwd, p) unless Pathname.new(p).absolute?
|
|
206
|
+
resolved = canonical_path(p)
|
|
207
|
+
begin
|
|
208
|
+
rel_pn = Pathname.new(resolved).relative_path_from(Pathname.new(@root))
|
|
209
|
+
rescue ArgumentError
|
|
210
|
+
return -1 # different mount/drive — outside the indexed root
|
|
211
|
+
end
|
|
212
|
+
parts = rel_pn.each_filename.to_a
|
|
213
|
+
return -1 if parts.first == ".." # outside the indexed root
|
|
214
|
+
return -1 if parts.any? { |pt| SKIP_DIRS.include?(pt) }
|
|
215
|
+
return -1 if parts[0...-1].any? { |pt| pt.start_with?(".") }
|
|
216
|
+
return -1 unless self.class.eligible?(rel_pn.basename.to_s)
|
|
217
|
+
|
|
218
|
+
stored = as_text(rel_pn.to_s)
|
|
219
|
+
unless File.exist?(resolved) # deleted → drop its chunks
|
|
220
|
+
@lock.synchronize { @conn.execute("DELETE FROM chunks WHERE path = ?", [stored]) }
|
|
221
|
+
return 0
|
|
222
|
+
end
|
|
223
|
+
index_path(resolved, label: stored)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ── query ───────────────────────────────────────────────────
|
|
227
|
+
# Build the FTS5 MATCH string: each folded query token (plus its light stem)
|
|
228
|
+
# as a quoted OR term. Quoting keeps it injection-safe.
|
|
229
|
+
def match_expr(query)
|
|
230
|
+
toks = []
|
|
231
|
+
Chunker.terms(query).each do |t|
|
|
232
|
+
toks << t
|
|
233
|
+
toks << Chunker.light_stem(t)
|
|
234
|
+
end
|
|
235
|
+
toks = toks.uniq.reject(&:empty?)
|
|
236
|
+
return nil if toks.empty?
|
|
237
|
+
|
|
238
|
+
toks.sort.map { |t| %("#{t}") }.join(" OR ")
|
|
239
|
+
end
|
|
240
|
+
private :match_expr
|
|
241
|
+
|
|
242
|
+
def self.testlike?(path)
|
|
243
|
+
s = (path || "").downcase
|
|
244
|
+
["/test", "test/", "test_", "_test.", ".test.", "/example", "example/"].any? { |p| s.include?(p) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def defines?(folded_body, symbols)
|
|
248
|
+
return false if symbols.empty?
|
|
249
|
+
|
|
250
|
+
alt = symbols.map { |s| Regexp.escape(s) }.join("|")
|
|
251
|
+
pat = Regexp.new("#{DEF_KW}\\s+(?:#{alt})(?![a-z0-9])")
|
|
252
|
+
!pat.match(folded_body).nil?
|
|
253
|
+
end
|
|
254
|
+
private :defines?
|
|
255
|
+
|
|
256
|
+
# Return the top-`k` chunks as [{ path:, score:, snippet: }], ranked by
|
|
257
|
+
# bm25() then reordered with two stable, proven passes:
|
|
258
|
+
# - source-over-tests: a test that merely mentions a symbol sinks below the
|
|
259
|
+
# source that defines it (skipped when the query is about tests);
|
|
260
|
+
# - definition-first: a chunk that DEFINES a queried symbol rises above
|
|
261
|
+
# chunks that only use it.
|
|
262
|
+
# Score is a higher-is-better float (sqlite's bm25 sign flipped).
|
|
263
|
+
def search(query, k: 5)
|
|
264
|
+
return [] unless @available
|
|
265
|
+
|
|
266
|
+
expr = match_expr(query)
|
|
267
|
+
return [] if expr.nil?
|
|
268
|
+
|
|
269
|
+
pool_n = [k * 3, 15].max
|
|
270
|
+
rows = @lock.synchronize do
|
|
271
|
+
@conn.execute(
|
|
272
|
+
"SELECT path, raw, body, bm25(chunks) AS s FROM chunks " \
|
|
273
|
+
"WHERE chunks MATCH ? ORDER BY s LIMIT ?",
|
|
274
|
+
[expr, pool_n]
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
return [] if rows.empty?
|
|
278
|
+
|
|
279
|
+
# candidate symbol names from the query (drop generic vocab).
|
|
280
|
+
symbols = Chunker.terms(query).select { |t| t.length >= 3 && !DEF_STOP.include?(t) }.uniq
|
|
281
|
+
about_tests = query.downcase.include?("test")
|
|
282
|
+
|
|
283
|
+
ordered = rows.each_with_index.sort_by do |row, idx|
|
|
284
|
+
path, _raw, body, = row
|
|
285
|
+
is_test = (!about_tests && self.class.testlike?(path)) ? 1 : 0
|
|
286
|
+
is_def = defines?(body, symbols) ? 0 : 1
|
|
287
|
+
[is_test, is_def, idx] # bm25 order (idx) breaks ties
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
ordered.first(k).map do |row, _idx|
|
|
291
|
+
path, raw, _body, s = row
|
|
292
|
+
{ path: path, score: (-s.to_f).round(6), snippet: snippet(raw) }
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def snippet(raw, limit: 280)
|
|
297
|
+
text = (raw || "").strip
|
|
298
|
+
return text if text.length <= limit
|
|
299
|
+
|
|
300
|
+
"#{text[0...limit].rstrip} ..."
|
|
301
|
+
end
|
|
302
|
+
private :snippet
|
|
303
|
+
|
|
304
|
+
# ── misc ────────────────────────────────────────────────────
|
|
305
|
+
def count
|
|
306
|
+
return 0 unless @available
|
|
307
|
+
|
|
308
|
+
@lock.synchronize { @conn.execute("SELECT count(*) FROM chunks").first.first }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def empty?
|
|
312
|
+
count.zero?
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def close
|
|
316
|
+
return if @conn.nil?
|
|
317
|
+
|
|
318
|
+
@lock.synchronize do
|
|
319
|
+
@conn.close
|
|
320
|
+
@conn = nil
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Tag a string as UTF-8 TEXT for SQLite binding. Path strings from
|
|
325
|
+
# Pathname#relative_path_from / File.realpath arrive as ASCII-8BIT (BINARY),
|
|
326
|
+
# which the sqlite3 gem binds as a BLOB — and in SQLite a BLOB never equals a
|
|
327
|
+
# TEXT column value, so `WHERE path = ?` would silently match nothing and the
|
|
328
|
+
# UPSERT's delete would leave stale rows behind. Re-tagging the same bytes as
|
|
329
|
+
# UTF-8 makes them bind as TEXT; invalid byte sequences are scrubbed so the
|
|
330
|
+
# bind can never raise. Filesystem paths on the supported platforms are UTF-8.
|
|
331
|
+
def as_text(str)
|
|
332
|
+
s = str.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
333
|
+
s.valid_encoding? ? s : s.scrub("?")
|
|
334
|
+
end
|
|
335
|
+
private :as_text
|
|
336
|
+
|
|
337
|
+
# ── path canonicalization ───────────────────────────────────
|
|
338
|
+
# Resolve symlinks like Python's Path.resolve() so a root under /var and a
|
|
339
|
+
# changed path reported under /private/var (macOS) compare equal.
|
|
340
|
+
def canonical_dir(dir)
|
|
341
|
+
File.exist?(dir) ? File.realpath(dir) : File.expand_path(dir)
|
|
342
|
+
end
|
|
343
|
+
private :canonical_dir
|
|
344
|
+
|
|
345
|
+
def canonical_path(p)
|
|
346
|
+
return File.realpath(p) if File.exist?(p)
|
|
347
|
+
|
|
348
|
+
d = File.dirname(p)
|
|
349
|
+
d = File.realpath(d) if File.exist?(d)
|
|
350
|
+
File.join(d, File.basename(p))
|
|
351
|
+
end
|
|
352
|
+
private :canonical_path
|
|
353
|
+
|
|
354
|
+
# Top-down walk that prunes skip-dirs and dotdirs, yielding each eligible
|
|
355
|
+
# file's absolute path in sorted order (mirrors the Python os.walk prune).
|
|
356
|
+
def walk(dir, &blk)
|
|
357
|
+
children = Dir.children(dir).sort
|
|
358
|
+
files = children.select { |c| File.file?(File.join(dir, c)) }.sort
|
|
359
|
+
files.each do |fn|
|
|
360
|
+
blk.call(File.join(dir, fn)) if self.class.eligible?(fn)
|
|
361
|
+
end
|
|
362
|
+
subdirs = children.select do |c|
|
|
363
|
+
full = File.join(dir, c)
|
|
364
|
+
File.directory?(full) && !SKIP_DIRS.include?(c) && !c.start_with?(".")
|
|
365
|
+
end
|
|
366
|
+
subdirs.sort.each { |d| walk(File.join(dir, d), &blk) }
|
|
367
|
+
end
|
|
368
|
+
private :walk
|
|
369
|
+
|
|
370
|
+
# ── process-wide shared index ─────────────────────────────────
|
|
371
|
+
# code_search (dev MCP) and the dev-reload reindex hook must share ONE index
|
|
372
|
+
# so a saved file is immediately searchable. Keyed by resolved db path.
|
|
373
|
+
@shared_contexts = {}
|
|
374
|
+
|
|
375
|
+
class << self
|
|
376
|
+
def db_key(db = nil)
|
|
377
|
+
base = db ? db.to_s : File.join(Dir.pwd, ".tina4", "context.db")
|
|
378
|
+
File.expand_path(base)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Get (or create) the process-wide Context at `db` (default
|
|
382
|
+
# <cwd>/.tina4/context.db). If `root` is given and the index is empty,
|
|
383
|
+
# builds it once. This is what code_search uses so the reload hook can keep
|
|
384
|
+
# the SAME index fresh.
|
|
385
|
+
def default_context(root: nil, db: nil)
|
|
386
|
+
key = db_key(db)
|
|
387
|
+
ctx = (@shared_contexts[key] ||= new(key))
|
|
388
|
+
ctx.index_root(root) if !root.nil? && ctx.available && ctx.empty?
|
|
389
|
+
ctx
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Return the already-created shared Context for `db` (or nil). Used by the
|
|
393
|
+
# reload hook so a file change reindexes an EXISTING index but never
|
|
394
|
+
# creates one on its own (nothing to keep fresh until code_search runs).
|
|
395
|
+
def existing_context(db: nil)
|
|
396
|
+
@shared_contexts[db_key(db)]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Test/teardown seam — forget every shared Context.
|
|
400
|
+
def clear_shared_contexts!
|
|
401
|
+
@shared_contexts.each_value { |c| c.close rescue nil }
|
|
402
|
+
@shared_contexts = {}
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -413,6 +413,19 @@ module Tina4
|
|
|
413
413
|
rescue StandardError => e
|
|
414
414
|
Tina4::Log.error("Re-discover on reload failed: #{e.message}")
|
|
415
415
|
end
|
|
416
|
+
# Keep the code Context index LIVE on the same reload trigger: reindex
|
|
417
|
+
# just the changed file (UPSERT) so the dev-MCP code_search reflects
|
|
418
|
+
# the edit immediately. Only touches an already-built index
|
|
419
|
+
# (existing_context never creates one); guarded so a context failure
|
|
420
|
+
# never breaks the reload.
|
|
421
|
+
begin
|
|
422
|
+
unless @reload_file.to_s.empty?
|
|
423
|
+
ctx = Tina4::Context.existing_context
|
|
424
|
+
ctx&.reindex_file(@reload_file)
|
|
425
|
+
end
|
|
426
|
+
rescue StandardError => e
|
|
427
|
+
Tina4::Log.error("Context reindex on reload failed: #{e.message}")
|
|
428
|
+
end
|
|
416
429
|
# WebSocket-primary reload: push an instant message to every browser
|
|
417
430
|
# connected on /__dev_reload. The toolbar client (and the dev-admin
|
|
418
431
|
# dashboard) act on this immediately — the mtime poll above is only a
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -1339,6 +1339,28 @@ module Tina4
|
|
|
1339
1339
|
Tina4::Docs.cached(project_root).method_spec(class_name.to_s, name.to_s)
|
|
1340
1340
|
}, "Single method spec (signature, summary, file, line) from the live API index")
|
|
1341
1341
|
|
|
1342
|
+
# ── Code/doc grounding (semantic FTS over this repo's source) ──
|
|
1343
|
+
# code_search is the fuzzy DUAL of the structural api_* tools:
|
|
1344
|
+
# api_* = exact signature lookup; code_search = "where/how is X done in
|
|
1345
|
+
# THIS codebase?" over source + docs (zero-dep SQLite FTS5). Backed by a
|
|
1346
|
+
# PROCESS-WIDE shared Context at .tina4/context.db so the dev-reload hook
|
|
1347
|
+
# keeps the SAME index fresh on every save. First call (or rebuild:true)
|
|
1348
|
+
# indexes src/ (falls back to the project root).
|
|
1349
|
+
code_root = -> { File.directory?(File.join(project_root, "src")) ? File.join(project_root, "src") : project_root }
|
|
1350
|
+
server.register_tool("code_search", lambda { |query:, k: 5, rebuild: false|
|
|
1351
|
+
ctx = Tina4::Context.default_context(
|
|
1352
|
+
root: code_root.call, db: File.join(project_root, ".tina4", "context.db")
|
|
1353
|
+
)
|
|
1354
|
+
unless ctx.available
|
|
1355
|
+
next { "error" => "SQLite FTS5 is not available in this Ruby's sqlite3 build; code_search is disabled." }
|
|
1356
|
+
end
|
|
1357
|
+
if rebuild == true || rebuild.to_s == "true"
|
|
1358
|
+
ctx.reset
|
|
1359
|
+
ctx.index_root(code_root.call)
|
|
1360
|
+
end
|
|
1361
|
+
ctx.search(query.to_s, k: k.to_i)
|
|
1362
|
+
}, "Fuzzy/semantic search over THIS project's source + docs (FTS5). Use for 'where/how is X done here?'; api_* for exact signatures.")
|
|
1363
|
+
|
|
1342
1364
|
# ── System Tools ──────────────────────────────────
|
|
1343
1365
|
server.register_tool("system_info", lambda {
|
|
1344
1366
|
{
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -158,14 +158,16 @@ module Tina4
|
|
|
158
158
|
|
|
159
159
|
# Resolve migrations directory: prefer src/migrations, fall back to migrations/
|
|
160
160
|
def resolve_migrations_dir
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
# Canonical location is migrations/ (project root) — matches the Python reference, the CLI,
|
|
162
|
+
# and auto-migrate. A legacy src/migrations/ is honoured only as a fallback.
|
|
164
163
|
root_dir = File.join(Dir.pwd, "migrations")
|
|
165
164
|
return root_dir if Dir.exist?(root_dir)
|
|
166
165
|
|
|
167
|
-
|
|
168
|
-
src_dir
|
|
166
|
+
src_dir = File.join(Dir.pwd, "src", "migrations")
|
|
167
|
+
return src_dir if Dir.exist?(src_dir)
|
|
168
|
+
|
|
169
|
+
# Neither exists yet -> default to the canonical migrations/ (created when needed)
|
|
170
|
+
root_dir
|
|
169
171
|
end
|
|
170
172
|
|
|
171
173
|
def ensure_tracking_table
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.62
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
@@ -296,6 +296,8 @@ files:
|
|
|
296
296
|
- lib/tina4/cli.rb
|
|
297
297
|
- lib/tina4/constants.rb
|
|
298
298
|
- lib/tina4/container.rb
|
|
299
|
+
- lib/tina4/context.rb
|
|
300
|
+
- lib/tina4/context/chunker.rb
|
|
299
301
|
- lib/tina4/cors.rb
|
|
300
302
|
- lib/tina4/crud.rb
|
|
301
303
|
- lib/tina4/database.rb
|