tina4ruby 3.13.61 → 3.13.63
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/auto_crud.rb +52 -10
- 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: 51cd83415eaad0fc64019fcbab89e92fb0cfb90cd9a3e1a3db6122499c14c83f
|
|
4
|
+
data.tar.gz: 9f1283a11d1f433d3ca39ae961db99bc73aa0e1f462336508c696ebbceaf9642
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 59ceaebe4dea54ec1b9e4894efee36cb1d98af2e9bdfc919033a055f5824d12f37f523177003b4a0f2497d0ca0f0a4c3086628f8ae1e29e8035beda64a7ea4b5
|
|
7
|
+
data.tar.gz: 6aa2471f6a38fe85d13e8832f68c23fd093ec0bbf9837f6ff6da220880428af9851c044cbf4d89f9cbd4ae64c1cc1c4f73e9373d3037902731018227fb6d0bbb
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -9,15 +9,32 @@ module Tina4
|
|
|
9
9
|
@models ||= []
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
#
|
|
13
|
-
|
|
12
|
+
# Per-model public-writes flag (model_class => Boolean).
|
|
13
|
+
# Default secure; only set true when a model is registered `public: true`.
|
|
14
|
+
# Mirrors tina4-php's `$this->public` map. Write routes stay gated unless
|
|
15
|
+
# a model is explicitly opted out.
|
|
16
|
+
def public_flags
|
|
17
|
+
@public_flags ||= {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Register a model for auto-CRUD.
|
|
21
|
+
#
|
|
22
|
+
# @param model_class [Class] the ORM subclass to expose
|
|
23
|
+
# @param public [Boolean] when true, the generated write routes
|
|
24
|
+
# (POST/PUT/DELETE) opt OUT of the framework's secure-by-default write
|
|
25
|
+
# gate — they are marked `.no_auth`. Default false keeps writes gated
|
|
26
|
+
# (a valid bearer token is required), matching the router's rule that
|
|
27
|
+
# POST/PUT/PATCH/DELETE require auth unless explicitly opened. Parity
|
|
28
|
+
# with tina4-python / tina4-php.
|
|
29
|
+
def register(model_class, public: false)
|
|
14
30
|
models << model_class unless models.include?(model_class)
|
|
31
|
+
public_flags[model_class] = public
|
|
15
32
|
end
|
|
16
33
|
|
|
17
34
|
# Generate REST endpoints for all registered models
|
|
18
35
|
def generate_routes(prefix: "/api")
|
|
19
36
|
models.each do |model_class|
|
|
20
|
-
generate_routes_for(model_class, prefix: prefix)
|
|
37
|
+
generate_routes_for(model_class, prefix: prefix, public: public_flags[model_class])
|
|
21
38
|
end
|
|
22
39
|
end
|
|
23
40
|
|
|
@@ -45,13 +62,25 @@ module Tina4
|
|
|
45
62
|
example
|
|
46
63
|
end
|
|
47
64
|
|
|
48
|
-
# Generate REST endpoints for a single model class
|
|
49
|
-
|
|
65
|
+
# Generate REST endpoints for a single model class.
|
|
66
|
+
#
|
|
67
|
+
# @param model_class [Class] the ORM subclass to expose
|
|
68
|
+
# @param prefix [String] URL prefix for the generated routes
|
|
69
|
+
# @param public [Boolean, nil] when true, the write routes
|
|
70
|
+
# (POST/PUT/DELETE) opt out of the secure-by-default gate via
|
|
71
|
+
# `.no_auth`. When nil (the default), the flag stored at register()
|
|
72
|
+
# time is used, falling back to false (secure). The read routes (GET)
|
|
73
|
+
# are never gated regardless.
|
|
74
|
+
def generate_routes_for(model_class, prefix: "/api", public: nil)
|
|
50
75
|
table = model_class.table_name
|
|
51
76
|
pk = model_class.primary_key_field || :id
|
|
52
77
|
pretty_name = table.to_s.split("_").map(&:capitalize).join(" ")
|
|
53
78
|
example_body = build_example(model_class)
|
|
54
79
|
|
|
80
|
+
# Explicit arg wins; otherwise fall back to the flag recorded by
|
|
81
|
+
# register(), otherwise secure-by-default.
|
|
82
|
+
is_public = public.nil? ? (public_flags[model_class] || false) : public
|
|
83
|
+
|
|
55
84
|
# GET /api/{table} -- list all with pagination, filtering, sorting
|
|
56
85
|
Tina4::Router.add("GET", "#{prefix}/#{table}", proc { |req, res|
|
|
57
86
|
begin
|
|
@@ -109,7 +138,7 @@ module Tina4
|
|
|
109
138
|
}, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s] })
|
|
110
139
|
|
|
111
140
|
# POST /api/{table} -- create record
|
|
112
|
-
Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
141
|
+
post_route = Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
113
142
|
begin
|
|
114
143
|
attributes = req.body_parsed
|
|
115
144
|
record = model_class.create(attributes)
|
|
@@ -136,8 +165,11 @@ module Tina4
|
|
|
136
165
|
}
|
|
137
166
|
})
|
|
138
167
|
|
|
168
|
+
# Secure-by-default: only opt out of the write gate when public: true.
|
|
169
|
+
post_route.no_auth if is_public
|
|
170
|
+
|
|
139
171
|
# PUT /api/{table}/{id} -- update record
|
|
140
|
-
Tina4::Router.add("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
172
|
+
put_route = Tina4::Router.add("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
141
173
|
begin
|
|
142
174
|
id = req.params["id"]
|
|
143
175
|
record = model_class.find_by_id(id.to_i)
|
|
@@ -174,8 +206,11 @@ module Tina4
|
|
|
174
206
|
}
|
|
175
207
|
})
|
|
176
208
|
|
|
209
|
+
# Secure-by-default: only opt out of the write gate when public: true.
|
|
210
|
+
put_route.no_auth if is_public
|
|
211
|
+
|
|
177
212
|
# DELETE /api/{table}/{id} -- delete record
|
|
178
|
-
Tina4::Router.add("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
213
|
+
delete_route = Tina4::Router.add("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
179
214
|
begin
|
|
180
215
|
id = req.params["id"]
|
|
181
216
|
record = model_class.find_by_id(id.to_i)
|
|
@@ -192,14 +227,20 @@ module Tina4
|
|
|
192
227
|
res.json({ error: e.message }, status: 500)
|
|
193
228
|
end
|
|
194
229
|
}, swagger_meta: { summary: "Delete #{pretty_name}", tags: [table.to_s] })
|
|
230
|
+
|
|
231
|
+
# Secure-by-default: only opt out of the write gate when public: true.
|
|
232
|
+
delete_route.no_auth if is_public
|
|
195
233
|
end
|
|
196
234
|
|
|
197
235
|
# Discover ORM model classes from a directory and register them.
|
|
198
236
|
#
|
|
199
237
|
# @param models_dir [String] directory to scan (default "src/orm")
|
|
200
238
|
# @param prefix [String] URL prefix for generated routes (default "/api")
|
|
239
|
+
# @param public [Boolean] when true, opt discovered models' write routes
|
|
240
|
+
# out of the secure-by-default gate (see .register). Default false keeps
|
|
241
|
+
# writes gated. Parity with tina4-python / tina4-php.
|
|
201
242
|
# @return [Array<String>] list of discovered model class names
|
|
202
|
-
def discover(models_dir = "src/orm", prefix: "/api")
|
|
243
|
+
def discover(models_dir = "src/orm", prefix: "/api", public: false)
|
|
203
244
|
discovered = []
|
|
204
245
|
return discovered unless Dir.exist?(models_dir)
|
|
205
246
|
|
|
@@ -210,7 +251,7 @@ module Tina4
|
|
|
210
251
|
# Find all ORM subclasses that have a table_name
|
|
211
252
|
ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM rescue false }.each do |klass|
|
|
212
253
|
next unless klass.respond_to?(:table_name) && klass.table_name
|
|
213
|
-
register(klass)
|
|
254
|
+
register(klass, public: public)
|
|
214
255
|
discovered << klass.name
|
|
215
256
|
end
|
|
216
257
|
|
|
@@ -220,6 +261,7 @@ module Tina4
|
|
|
220
261
|
|
|
221
262
|
def clear!
|
|
222
263
|
@models = []
|
|
264
|
+
@public_flags = {}
|
|
223
265
|
end
|
|
224
266
|
|
|
225
267
|
# Alias for parity with other frameworks
|
|
@@ -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.63
|
|
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
|