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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a1fe8987a4b278ffa81f920f3a04af6747b306c06d991543f9cc9813d4c4fd9
4
- data.tar.gz: fbc9186a54212acd053e83ba9980faf482b65e4831c7ef912d9717c741d662f4
3
+ metadata.gz: 2654a6c8f7c322aeb7b6719ff4e908840e4dec23afa49dc02e165a0b0c74b0c7
4
+ data.tar.gz: a5b0f4e3813c0597f7d6734ba5472a4641f70af228c8ad9513166519743f0e0b
5
5
  SHA512:
6
- metadata.gz: 29bc302b4ed44939105eaad0181fe863ed655b4bc28a3271fce8964943d61c4240f5f12a8ff2d9da221588445d5e4ff7071eaebac52f3bb632e4c3d9051b7d64
7
- data.tar.gz: 5911bf0ffa529102fa933ae7841b64cef1b6bbe02bd6b9a693ea2db8bce4bd290f986291b0f3313e7eb42a6f5b20f1d44552cf642a12b7d65c30de6500e71bd1
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
@@ -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
  {
@@ -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
- src_dir = File.join(Dir.pwd, "src", "migrations")
162
- return src_dir if Dir.exist?(src_dir)
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
- # Default to src/migrations (will be created when needed)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.61"
4
+ VERSION = "3.13.62"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -51,6 +51,7 @@ require_relative "tina4/test_client"
51
51
  require_relative "tina4/test"
52
52
  require_relative "tina4/docs"
53
53
  require_relative "tina4/docstore"
54
+ require_relative "tina4/context"
54
55
  require_relative "tina4/mcp"
55
56
  require_relative "tina4/realtime"
56
57
 
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.61
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