tina4-nodejs 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.61",
3
+ "version": "3.13.63",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -0,0 +1,161 @@
1
+ // Tina4 Node.js
2
+ //
3
+ // Text folding + chunking for the Context subsystem.
4
+ //
5
+ // A thin, idiomatic TypeScript port of tina4-python's context/chunker.py
6
+ // (itself the proven slice of neemee's tokenizer/chunkers). Zero dependencies —
7
+ // nothing here touches SQLite; it produces the (folded body, raw text) pairs the
8
+ // FTS5 index stores.
9
+
10
+ // join comma-grouped numbers ("24,601" -> "24601") and split camelCase
11
+ // ("ForeignKeyField" -> "foreign key field") so a query token reaches a
12
+ // code identifier. snake_case already splits for free at the tokenizer.
13
+ const NUM_COMMA = /(?<=\d),(?=\d)/g;
14
+ const CAMEL = /(?<=[a-z0-9])(?=[A-Z])/g;
15
+ const WORD_RE = /[a-z0-9]+/g;
16
+
17
+ // Sentence boundary = end punctuation FOLLOWED BY whitespace/EOL, or a newline.
18
+ // NOT a bare '.', which would shred embedded code in prose ("db.fetch()" ->
19
+ // "db. fetch()"). Intra-token dots (method calls, module paths) stay intact.
20
+ const SENT = /(?<=[.!?])\s+|\n+/;
21
+
22
+ // Chunk boundaries across languages: python defs/classes/decorators, php/js
23
+ // functions and methods, ts exports/interfaces, php traits, and Object Pascal
24
+ // unit/type/routine headers (Delphi is case-insensitive, accept either case).
25
+ // Anchored at line start; tested per line (parity with python's re.match).
26
+ const TOPLEVEL =
27
+ /^(async def |def |class |@\w|\s*(?:public |private |protected |static |final |abstract )*function \w|(?:export )?(?:default )?(?:abstract )?class |interface |trait |export (?:const|function|interface|type|async) |[Uu]nit |[Pp]rocedure |[Ff]unction |[Cc]onstructor |[Dd]estructor |[Tt]ype$)/;
28
+
29
+ /**
30
+ * Lowercase, strip diacritics, join comma-grouped numbers, split camelCase.
31
+ *
32
+ * Applied symmetrically to the indexed `body` and to query tokens so matching
33
+ * is consistent: a query for `field` reaches `IntegerField`. The trailing
34
+ * NFKD-normalise + strip-non-ASCII mirrors python's
35
+ * `unicodedata.normalize("NFKD", ...).encode("ascii", "ignore")`.
36
+ */
37
+ export function fold(text: string): string {
38
+ const split = text.replace(NUM_COMMA, "").replace(CAMEL, " ").toLowerCase();
39
+ // eslint-disable-next-line no-control-regex
40
+ return split.normalize("NFKD").replace(/[^\x00-\x7f]/g, "");
41
+ }
42
+
43
+ /**
44
+ * Fold simple plurals: strip one trailing 's', never from ss/us/is endings
45
+ * (class, status, axis). QUERY-side only — so `fields` in a question also
46
+ * reaches `Field` definitions.
47
+ */
48
+ export function lightStem(token: string): string {
49
+ if (token.length > 3 && token.endsWith("s") && !/(?:ss|us|is)$/.test(token)) {
50
+ return token.slice(0, -1);
51
+ }
52
+ return token;
53
+ }
54
+
55
+ /** Accent-folded lowercase alphanumeric tokens (digits kept). */
56
+ export function terms(text: string): string[] {
57
+ return fold(text).match(WORD_RE) ?? [];
58
+ }
59
+
60
+ /**
61
+ * Split into lines the way python's `str.splitlines()` does: on any line
62
+ * terminator, WITHOUT a trailing empty element for a final newline. A plain
63
+ * `text.split("\n")` would append a spurious "" for "a\n" — this drops it so
64
+ * chunk boundaries and line counts match the reference.
65
+ */
66
+ function splitLines(text: string): string[] {
67
+ const lines = text.split(/\r\n|\r|\n/);
68
+ if (lines.length > 0 && lines[lines.length - 1] === "" && /[\r\n]$/.test(text)) {
69
+ lines.pop();
70
+ }
71
+ return lines;
72
+ }
73
+
74
+ /**
75
+ * Pack line-segments into chunks of at most `maxLines` lines, hard-splitting
76
+ * any single oversized segment.
77
+ */
78
+ function pack(segments: string[][], maxLines: number): string[][] {
79
+ const chunks: string[][] = [];
80
+ let cur: string[] = [];
81
+ for (let seg of segments) {
82
+ while (seg.length > maxLines) {
83
+ if (cur.length) {
84
+ chunks.push(cur);
85
+ cur = [];
86
+ }
87
+ chunks.push(seg.slice(0, maxLines));
88
+ seg = seg.slice(maxLines);
89
+ }
90
+ if (cur.length && cur.length + seg.length > maxLines) {
91
+ chunks.push(cur);
92
+ cur = [];
93
+ }
94
+ cur = cur.concat(seg);
95
+ }
96
+ if (cur.length) {
97
+ chunks.push(cur);
98
+ }
99
+ return chunks;
100
+ }
101
+
102
+ /**
103
+ * Chunk source on top-level def/class/decorator boundaries (sentence chunking
104
+ * shreds code). Segments are packed up to `maxLines`, and every chunk starts
105
+ * with a `# file: <path>` line so the path's tokens are indexed — 'where is the
106
+ * router?' should match core/router.ts by name.
107
+ *
108
+ * Returns a list of `[index, chunkText]` pairs.
109
+ */
110
+ export function chunkCode(text: string, path = "", maxLines = 60): Array<[number, string]> {
111
+ const lines = splitLines(text);
112
+ let bounds: number[] = [];
113
+ for (let i = 0; i < lines.length; i++) {
114
+ if (TOPLEVEL.test(lines[i])) bounds.push(i);
115
+ }
116
+ if (bounds.length === 0 || bounds[0] !== 0) {
117
+ bounds = [0, ...bounds];
118
+ }
119
+ const ends = [...bounds.slice(1), lines.length];
120
+ const segments: string[][] = [];
121
+ for (let k = 0; k < bounds.length; k++) {
122
+ segments.push(lines.slice(bounds[k], ends[k]));
123
+ }
124
+
125
+ const header = path ? `# file: ${path}` : null;
126
+ const packed = pack(segments, maxLines);
127
+ const out: Array<[number, string]> = [];
128
+ for (let i = 0; i < packed.length; i++) {
129
+ const body = (header ? [header, ...packed[i]] : packed[i]).join("\n");
130
+ out.push([i, body]);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Chunk prose/docs into sentence-packed windows of at most `maxWords` words.
137
+ * Returns a list of `[index, chunkText]` pairs.
138
+ */
139
+ export function chunkText(text: string, maxWords = 350): Array<[number, string]> {
140
+ const sents = text
141
+ .split(SENT)
142
+ .map((s) => s.trim())
143
+ .filter((s) => s.length > 0);
144
+ const chunks: string[] = [];
145
+ let cur: string[] = [];
146
+ let curWords = 0;
147
+ for (const s of sents) {
148
+ const w = s.split(/\s+/).filter(Boolean).length;
149
+ if (cur.length && curWords + w > maxWords) {
150
+ chunks.push(cur.join(" "));
151
+ cur = [];
152
+ curWords = 0;
153
+ }
154
+ cur.push(s);
155
+ curWords += w;
156
+ }
157
+ if (cur.length) {
158
+ chunks.push(cur.join(" "));
159
+ }
160
+ return chunks.map((c, i): [number, string] => [i, c]);
161
+ }
@@ -0,0 +1,424 @@
1
+ // Tina4 Node.js
2
+ //
3
+ // Context — a native, zero-dependency code/doc grounding index.
4
+ //
5
+ // Lets a Tina4 app ground its own AI assistant on its own source, offline:
6
+ // it walks the project, chunks code on def/class boundaries and docs as prose,
7
+ // and answers keyword/fuzzy queries over a SQLite FTS5 index (Node's built-in
8
+ // `node:sqlite` — FTS5 + `bm25()` are compiled in, so NO new dependency; the
9
+ // same module the ORM/session/docstore subsystems already use).
10
+ //
11
+ // import { Context } from "@tina4/core";
12
+ // const ctx = new Context(".tina4/context.db");
13
+ // ctx.indexRoot("src");
14
+ // ctx.search("where is the auth token issued?", 5);
15
+ // // -> [{ path: "src/auth.ts", score: 2.31, snippet: "..." }, ...]
16
+ //
17
+ // A faithful TypeScript port of tina4-python's tina4_python/context/__init__.py
18
+ // (the proven slice of neemee's SqliteFTS + source-over-tests / definition-first
19
+ // reordering). It COMPLEMENTS the api_* reflection tools: api_* is exact
20
+ // structural lookup, Context is fuzzy/semantic FTS over source + docs.
21
+ //
22
+ // On-disk index defaults to `.tina4/context.db` (gitignored). Guards a sqlite
23
+ // build without FTS5: if absent, the Context degrades to safe no-ops rather than
24
+ // crashing the app.
25
+ //
26
+ // node:sqlite is synchronous and JavaScript is single-threaded, so — unlike the
27
+ // Python port — no lock is needed: every method runs to completion before the
28
+ // event loop can dispatch another.
29
+
30
+ import { DatabaseSync } from "node:sqlite";
31
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
32
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
33
+
34
+ import { chunkCode, chunkText, fold, lightStem, terms } from "./chunker.js";
35
+
36
+ // File classification — mirrors neemee's repo walk.
37
+ const CODE_EXTS = new Set([
38
+ ".py", ".php", ".js", ".mjs", ".ts", ".rb",
39
+ ".pas", ".dpr", ".dpk", ".inc", ".dfm", ".fmx",
40
+ ]);
41
+ const DOC_EXTS = new Set([".md", ".txt", ".rst", ".twig", ".html"]);
42
+ // deploy/CLI/env answers live in config files, not sources — chunk as code
43
+ // (line windows), since sentence chunking shreds YAML/Dockerfiles.
44
+ const CONFIG_EXTS = new Set([".toml", ".yml", ".yaml"]);
45
+ const SPECIAL_FILES = new Set([
46
+ "dockerfile", "makefile", "docker-compose.yml",
47
+ "package.json", "composer.json",
48
+ ".env.example", ".env.sample",
49
+ ]);
50
+ // Same dirs neemee skips, plus Tina4 runtime dirs that hold no source of truth
51
+ // (our own index/backups, session blobs, logs).
52
+ const SKIP_DIRS = new Set([
53
+ ".git", "__pycache__", "node_modules", "vendor", "dist",
54
+ "build", "coverage", ".idea", ".venv", "venv", ".pytest_cache",
55
+ ".tina4", "sessions", "logs",
56
+ ]);
57
+
58
+ // generic question/code vocabulary that never NAMES a symbol — kept small.
59
+ const DEF_STOP = new Set(
60
+ ("the and what how does can which where when who why list all available " +
61
+ "module class function functions method methods def get set new return " +
62
+ "import from with that this are is was for into use used").split(" "),
63
+ );
64
+
65
+ // a chunk that DEFINES a queried symbol ('def get_token', 'class Widget') should
66
+ // out-rank one that merely USES it. Matched against the folded body.
67
+ const DEF_KW = "(?:async def|def|class|function|fn|func|interface|trait)";
68
+
69
+ /** True if this build of node:sqlite supports FTS5. */
70
+ export function fts5Supported(): boolean {
71
+ try {
72
+ const conn = new DatabaseSync(":memory:");
73
+ try {
74
+ conn.exec("CREATE VIRTUAL TABLE _probe USING fts5(x)");
75
+ return true;
76
+ } finally {
77
+ conn.close();
78
+ }
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ function escapeRegex(s: string): string {
85
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
86
+ }
87
+
88
+ /**
89
+ * Resolve `abs` to its canonical (symlink-free) form, matching Python's
90
+ * `Path.resolve()`. If the file itself doesn't exist yet (a delete event), fall
91
+ * back to canonicalising the parent directory and re-joining the basename, then
92
+ * to the lexical path. Needed because macOS temp dirs (and /var) are symlinks,
93
+ * so a raw `process.cwd()` (already real) and a lexically-resolved index root
94
+ * would otherwise mismatch and drop every reindex as "outside root".
95
+ */
96
+ function realResolve(abs: string): string {
97
+ try {
98
+ return realpathSync(abs);
99
+ } catch {
100
+ /* not created (deleted file) — fall through */
101
+ }
102
+ try {
103
+ return join(realpathSync(dirname(abs)), basename(abs));
104
+ } catch {
105
+ return abs;
106
+ }
107
+ }
108
+
109
+ export interface SearchHit {
110
+ path: string;
111
+ score: number;
112
+ snippet: string;
113
+ }
114
+
115
+ interface ChunkRow {
116
+ path: string;
117
+ raw: string;
118
+ body: string;
119
+ s: number;
120
+ }
121
+
122
+ /**
123
+ * A SQLite FTS5 index over a project's source + docs.
124
+ *
125
+ * - indexPath(file, label?) upsert one file (delete-by-path, re-chunk, insert)
126
+ * - indexRoot(root) walk a tree, index every eligible file
127
+ * - search(query, k) [{path, score, snippet}] ranked by bm25() with
128
+ * source-over-tests + definition-first reordering
129
+ * - reindexFile(changed) upsert one changed file against the indexed root
130
+ */
131
+ export class Context {
132
+ path: string;
133
+ root: string | null = null;
134
+ available: boolean;
135
+ private conn: DatabaseSync | null = null;
136
+
137
+ /**
138
+ * @param dbPath on-disk index file (its parent dir is created).
139
+ * @param fts5Check overrides FTS5 detection (used by tests to exercise the
140
+ * graceful-degradation path); defaults to a real probe.
141
+ */
142
+ constructor(dbPath = "./.tina4/context.db", fts5Check?: () => boolean) {
143
+ this.path = String(dbPath);
144
+ const check = fts5Check ?? fts5Supported;
145
+ this.available = Boolean(check());
146
+ if (!this.available) return;
147
+ const parent = dirname(this.path);
148
+ if (parent !== "" && parent !== ".") {
149
+ mkdirSync(parent, { recursive: true });
150
+ }
151
+ this.conn = new DatabaseSync(this.path);
152
+ this.ensureTable();
153
+ }
154
+
155
+ /** Whether this Node build's node:sqlite supports FTS5. */
156
+ static fts5Available(): boolean {
157
+ return fts5Supported();
158
+ }
159
+
160
+ private ensureTable(): void {
161
+ // `body` holds fold(text) so query/index tokenization is symmetric; `raw`
162
+ // (UNINDEXED) keeps the original text to return as a snippet; `path`/`cid`
163
+ // (UNINDEXED) are metadata used for upsert + citation.
164
+ this.conn!.exec(
165
+ "CREATE VIRTUAL TABLE IF NOT EXISTS chunks " +
166
+ "USING fts5(cid UNINDEXED, path UNINDEXED, raw UNINDEXED, body)",
167
+ );
168
+ }
169
+
170
+ /** Drop and recreate the index (full rebuild starting point). */
171
+ reset(): void {
172
+ if (!this.available) return;
173
+ this.conn!.exec("DROP TABLE IF EXISTS chunks");
174
+ this.ensureTable();
175
+ }
176
+
177
+ // ── indexing ───────────────────────────────────────────────
178
+ private static chunksFor(label: string, text: string): Array<[number, string]> {
179
+ const ext = extname(label).toLowerCase();
180
+ const special = SPECIAL_FILES.has(basename(label).toLowerCase());
181
+ if (CODE_EXTS.has(ext) || CONFIG_EXTS.has(ext) || special) {
182
+ return chunkCode(text, label);
183
+ }
184
+ return chunkText(text);
185
+ }
186
+
187
+ /**
188
+ * UPSERT one file into the index: delete this path's existing chunks,
189
+ * re-chunk the current contents, insert. `label` is the stored/citation path
190
+ * (defaults to `file`) and MUST be stable across calls for the same file so
191
+ * the delete targets the right rows. Returns rows inserted.
192
+ */
193
+ indexPath(file: string, label?: string): number {
194
+ if (!this.available) return 0;
195
+ const stored = label != null ? String(label) : String(file);
196
+ let text: string;
197
+ try {
198
+ text = readFileSync(file, "utf-8");
199
+ } catch {
200
+ return 0;
201
+ }
202
+ const rows = Context.chunksFor(stored, text).map(
203
+ ([i, chunk]): [string, string, string, string] => [`${stored}:${i}`, stored, chunk, fold(chunk)],
204
+ );
205
+ this.conn!.prepare("DELETE FROM chunks WHERE path = ?").run(stored);
206
+ if (rows.length) {
207
+ const ins = this.conn!.prepare("INSERT INTO chunks(cid, path, raw, body) VALUES (?, ?, ?, ?)");
208
+ for (const r of rows) ins.run(r[0], r[1], r[2], r[3]);
209
+ }
210
+ return rows.length;
211
+ }
212
+
213
+ /**
214
+ * The per-file filter used by both indexRoot and reindexFile. Directory
215
+ * skipping is handled separately.
216
+ */
217
+ private static eligible(filename: string): boolean {
218
+ const fn = filename.toLowerCase();
219
+ if (fn.endsWith(".min.js")) return false;
220
+ const ext = extname(fn);
221
+ return CODE_EXTS.has(ext) || DOC_EXTS.has(ext) || CONFIG_EXTS.has(ext) || SPECIAL_FILES.has(fn);
222
+ }
223
+
224
+ /**
225
+ * Walk `root`, indexing every eligible file (skips vendor/build/runtime
226
+ * dirs). Paths are stored RELATIVE to `root` for clean citations. Records
227
+ * `root` so reindexFile can relabel a changed file consistently. Returns the
228
+ * total number of chunks inserted.
229
+ */
230
+ indexRoot(root: string): number {
231
+ if (!this.available) return 0;
232
+ const rootAbs = realResolve(resolve(String(root)));
233
+ this.root = rootAbs;
234
+ let total = 0;
235
+ const walk = (dir: string): void => {
236
+ const entries = readdirSync(dir, { withFileTypes: true });
237
+ const files: string[] = [];
238
+ const subdirs: string[] = [];
239
+ for (const e of entries) {
240
+ if (e.isDirectory()) {
241
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) subdirs.push(e.name);
242
+ } else if (e.isFile()) {
243
+ files.push(e.name);
244
+ }
245
+ }
246
+ files.sort();
247
+ for (const fn of files) {
248
+ if (!Context.eligible(fn)) continue;
249
+ const full = join(dir, fn);
250
+ const rel = relative(rootAbs, full);
251
+ total += this.indexPath(full, rel);
252
+ }
253
+ for (const d of subdirs) walk(join(dir, d));
254
+ };
255
+ walk(rootAbs);
256
+ return total;
257
+ }
258
+
259
+ /**
260
+ * Re-index a single changed file into the LIVE index — the hook the dev
261
+ * WebSocket reload trigger (POST /__dev/api/reload) calls so code_search
262
+ * tracks edits without a rebuild. Resolves `changedPath` against the indexed
263
+ * root, then: outside root / under a skip-or-dot dir / ineligible → skip (-1);
264
+ * deleted → drop its chunks (0); otherwise UPSERT (rows). No-op (-1) until
265
+ * indexRoot has run (nothing to keep fresh yet).
266
+ */
267
+ reindexFile(changedPath: string): number {
268
+ if (!this.available || this.root === null) return -1;
269
+ const raw = String(changedPath);
270
+ // the reload trigger reports paths relative to the project root (cwd during
271
+ // `tina4 serve`); the index root may be a subdir like src/.
272
+ const abs = isAbsolute(raw) ? raw : join(process.cwd(), raw);
273
+ const resolved = realResolve(resolve(abs));
274
+ const rel = relative(this.root, resolved);
275
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
276
+ return -1; // outside the indexed root
277
+ }
278
+ const parts = rel.split(/[\\/]/);
279
+ const dirParts = parts.slice(0, -1);
280
+ if (parts.some((seg) => SKIP_DIRS.has(seg)) || dirParts.some((seg) => seg.startsWith("."))) {
281
+ return -1; // under a skipped / dot dir
282
+ }
283
+ if (!Context.eligible(basename(rel))) return -1;
284
+ const stored = rel;
285
+ if (!existsSync(abs)) {
286
+ // deleted → drop its chunks
287
+ this.conn!.prepare("DELETE FROM chunks WHERE path = ?").run(stored);
288
+ return 0;
289
+ }
290
+ return this.indexPath(abs, stored);
291
+ }
292
+
293
+ // ── query ──────────────────────────────────────────────────
294
+ private matchExpr(query: string): { expr: string | null; toks: Set<string> } {
295
+ const toks = new Set<string>();
296
+ for (const t of terms(query)) {
297
+ toks.add(t);
298
+ toks.add(lightStem(t));
299
+ }
300
+ toks.delete("");
301
+ if (toks.size === 0) return { expr: null, toks };
302
+ // Quoting keeps each term injection-safe inside the FTS5 MATCH string.
303
+ const expr = [...toks].sort().map((t) => `"${t}"`).join(" OR ");
304
+ return { expr, toks };
305
+ }
306
+
307
+ private static isTestlike(path: string): boolean {
308
+ const s = (path || "").toLowerCase();
309
+ return ["/test", "test/", "test_", "_test.", ".test.", "/example", "example/"].some((p) => s.includes(p));
310
+ }
311
+
312
+ private static defines(foldedBody: string, symbols: Set<string>): boolean {
313
+ if (symbols.size === 0) return false;
314
+ const alt = [...symbols].map(escapeRegex).join("|");
315
+ const pat = new RegExp(`${DEF_KW}\\s+(?:${alt})(?![a-z0-9])`);
316
+ return pat.test(foldedBody);
317
+ }
318
+
319
+ /**
320
+ * Return the top-`k` chunks as `[{path, score, snippet}]`, ranked by `bm25()`
321
+ * then reordered with two stable, proven passes:
322
+ * - source-over-tests: a test that merely mentions a symbol sinks below the
323
+ * source that defines it (skipped when the query is about tests);
324
+ * - definition-first: a chunk that DEFINES a queried symbol rises above
325
+ * chunks that only use it.
326
+ * Score is a higher-is-better float (sqlite's bm25 sign flipped).
327
+ */
328
+ search(query: string, k = 5): SearchHit[] {
329
+ if (!this.available) return [];
330
+ const { expr } = this.matchExpr(query);
331
+ if (expr === null) return [];
332
+ const poolN = Math.max(k * 3, 15);
333
+ const rows = this.conn!
334
+ .prepare("SELECT path, raw, body, bm25(chunks) AS s FROM chunks WHERE chunks MATCH ? ORDER BY s LIMIT ?")
335
+ .all(expr, poolN) as unknown as ChunkRow[];
336
+ if (rows.length === 0) return [];
337
+
338
+ // candidate symbol names from the query (drop generic vocab).
339
+ const symbols = new Set(terms(query).filter((t) => t.length >= 3 && !DEF_STOP.has(t)));
340
+ const aboutTests = query.toLowerCase().includes("test");
341
+
342
+ const isTest = (p: string): number => (!aboutTests && Context.isTestlike(p) ? 1 : 0);
343
+ const isDef = (b: string): number => (Context.defines(b, symbols) ? 0 : 1);
344
+
345
+ const ordered = rows
346
+ .map((r, i) => ({ r, i }))
347
+ .sort((a, b) => {
348
+ const at = isTest(a.r.path);
349
+ const bt = isTest(b.r.path);
350
+ if (at !== bt) return at - bt;
351
+ const ad = isDef(a.r.body);
352
+ const bd = isDef(b.r.body);
353
+ if (ad !== bd) return ad - bd;
354
+ return a.i - b.i; // bm25 order breaks ties
355
+ });
356
+
357
+ return ordered.slice(0, k).map(({ r }) => ({
358
+ path: r.path,
359
+ score: Math.round(-Number(r.s) * 1e6) / 1e6, // bm25: more negative = better
360
+ snippet: Context.snippet(r.raw),
361
+ }));
362
+ }
363
+
364
+ private static snippet(raw: string, limit = 280): string {
365
+ const text = (raw || "").trim();
366
+ if (text.length <= limit) return text;
367
+ return text.slice(0, limit).replace(/\s+$/, "") + " ...";
368
+ }
369
+
370
+ // ── misc ───────────────────────────────────────────────────
371
+ count(): number {
372
+ if (!this.available) return 0;
373
+ const row = this.conn!.prepare("SELECT count(*) AS c FROM chunks").get() as { c: number };
374
+ return row.c;
375
+ }
376
+
377
+ isEmpty(): boolean {
378
+ return this.count() === 0;
379
+ }
380
+
381
+ close(): void {
382
+ if (this.conn !== null) {
383
+ this.conn.close();
384
+ this.conn = null;
385
+ }
386
+ }
387
+ }
388
+
389
+ // ── process-wide shared index ──────────────────────────────────
390
+ // code_search (dev MCP) and the dev-reload reindex hook must share ONE index so
391
+ // a saved file is immediately searchable. Keyed by resolved db path.
392
+ export const _sharedContexts = new Map<string, Context>();
393
+
394
+ function dbKey(db?: string): string {
395
+ return resolve(db ? String(db) : join(process.cwd(), ".tina4", "context.db"));
396
+ }
397
+
398
+ /**
399
+ * Get (or create) the process-wide Context at `db` (default
400
+ * `<cwd>/.tina4/context.db`). If `root` is given and the index is empty, builds
401
+ * it once. This is what code_search uses so the reload hook can keep the SAME
402
+ * index fresh.
403
+ */
404
+ export function defaultContext(root?: string, db?: string): Context {
405
+ const key = dbKey(db);
406
+ let ctx = _sharedContexts.get(key);
407
+ if (ctx === undefined) {
408
+ ctx = new Context(key);
409
+ _sharedContexts.set(key, ctx);
410
+ }
411
+ if (root != null && ctx.available && ctx.isEmpty()) {
412
+ ctx.indexRoot(root);
413
+ }
414
+ return ctx;
415
+ }
416
+
417
+ /**
418
+ * Return the already-created shared Context for `db` (or undefined). Used by the
419
+ * reload hook so a file change reindexes an EXISTING index but never creates one
420
+ * on its own (nothing to keep fresh until code_search runs).
421
+ */
422
+ export function existingContext(db?: string): Context | undefined {
423
+ return _sharedContexts.get(dbKey(db));
424
+ }
@@ -703,6 +703,21 @@ const handleReload: RouteHandler = async (req, res) => {
703
703
  console.error(` Re-discover on reload failed:`, err);
704
704
  }
705
705
 
706
+ // Keep the code Context index LIVE on the same reload trigger: reindex just
707
+ // the changed file (UPSERT) so the dev-MCP code_search reflects the edit
708
+ // immediately. existingContext() only touches an already-built index (it never
709
+ // creates one — nothing to keep fresh until code_search has run), and the
710
+ // whole block is guarded so a context failure never breaks the reload.
711
+ try {
712
+ if (_reloadFile) {
713
+ const { existingContext } = await import("./context/index.js");
714
+ const ctx = existingContext();
715
+ if (ctx) ctx.reindexFile(_reloadFile);
716
+ }
717
+ } catch (err) {
718
+ console.error(` Context reindex on reload failed:`, err);
719
+ }
720
+
706
721
  // WebSocket-primary reload: push an instant message to every browser
707
722
  // connected on /__dev_reload. The toolbar client (and the dev-admin
708
723
  // dashboard) act on this immediately — the mtime poll is only a fallback for
@@ -75,6 +75,8 @@ export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete,
75
75
  export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
76
76
  export { Api } from "./api.js";
77
77
  export type { ApiResult } from "./api.js";
78
+ export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
79
+ export type { SearchHit } from "./context/index.js";
78
80
  export { Events } from "./events.js";
79
81
  export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage } from "./devAdmin.js";
80
82
  export {
@@ -1056,7 +1056,8 @@ function verifyNodeSyntax(absPath: string, relPath: string): string | null {
1056
1056
  let _lastCacheStats: Record<string, unknown> | null = null;
1057
1057
 
1058
1058
  /**
1059
- * Register all 24 built-in dev tools on the given McpServer.
1059
+ * Register all built-in dev tools on the given McpServer (the api_* reflection
1060
+ * tools plus code_search, the fuzzy FTS grounding tool).
1060
1061
  */
1061
1062
  export function registerDevTools(server: McpServer): void {
1062
1063
  const projectRoot = path.resolve(process.cwd());
@@ -2070,6 +2071,48 @@ export function registerDevTools(server: McpServer): void {
2070
2071
  { name: "name", type: "string" },
2071
2072
  ]),
2072
2073
  );
2074
+
2075
+ // ── Code/doc grounding (context subsystem) ──────────────────
2076
+ // Sibling of api_* but the DUAL of it: api_* is exact structural reflection
2077
+ // (class/method signatures); code_search is fuzzy/semantic FTS over the
2078
+ // project's own SOURCE + docs. Use code_search for "where/how is X done in
2079
+ // THIS codebase?" and api_* for "what's the signature of X?". Backed by a
2080
+ // zero-dependency node:sqlite FTS5 index at .tina4/context.db, held as a
2081
+ // PROCESS-WIDE shared Context (context.defaultContext) so the dev-reload hook
2082
+ // (handleReload) can keep the SAME index fresh on every file save.
2083
+ server.registerTool(
2084
+ "code_search",
2085
+ (args) => {
2086
+ try {
2087
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2088
+ const { defaultContext } = req("./context/index.js") as typeof import("./context/index.js");
2089
+ // Index src/ when present (falls back to the project root), mirroring
2090
+ // the Python master's _code_root().
2091
+ const srcDir = path.join(projectRoot, "src");
2092
+ const root = fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory() ? srcDir : projectRoot;
2093
+ const ctx = defaultContext(root, path.join(projectRoot, ".tina4", "context.db"));
2094
+ if (!ctx.available) {
2095
+ return { error: "SQLite FTS5 is not available in this Node build; code_search is disabled." };
2096
+ }
2097
+ if (args.rebuild) {
2098
+ ctx.reset();
2099
+ ctx.indexRoot(root);
2100
+ }
2101
+ const k = parseInt(String(args.k ?? 5), 10) || 5;
2102
+ return ctx.search((args.query as string) || "", k);
2103
+ } catch (e) {
2104
+ return { error: (e as Error).message };
2105
+ }
2106
+ },
2107
+ "Fuzzy/semantic search over THIS project's own source + docs (node:sqlite FTS5, zero-dep). " +
2108
+ "Ranks definitions above tests that merely mention a symbol. Use for 'where is X done here?'; " +
2109
+ "use api_* for exact signatures. Returns [{path, score, snippet}].",
2110
+ schemaFromParams([
2111
+ { name: "query", type: "string" },
2112
+ { name: "k", type: "integer", default: 5 },
2113
+ { name: "rebuild", type: "boolean", default: false },
2114
+ ]),
2115
+ );
2073
2116
  }
2074
2117
 
2075
2118
  /** Alias for registerDevTools — parity with PHP/Ruby/Python. */
@@ -14,28 +14,47 @@ import { validate } from "./validation.js";
14
14
  * PUT /api/{table}/{id} — update record
15
15
  * DELETE /api/{table}/{id} — delete record
16
16
  */
17
+ /**
18
+ * Options accepted by the AutoCrud registration API.
19
+ *
20
+ * `public` is the cross-backend escape hatch (parity with python's `public=True`
21
+ * and php's `bool $public`). Write routes (POST/PUT/DELETE) are secure-by-default
22
+ * — the router gates them unless a def sets `secure: false`. Set `public: true`
23
+ * to open the generated write routes explicitly. Reads (GET) are always public.
24
+ */
25
+ export interface AutoCrudOptions {
26
+ /** When true, the generated write routes (POST/PUT/DELETE) are OPEN (secure:false). Default false → secure. */
27
+ public?: boolean;
28
+ }
29
+
17
30
  export class AutoCrud {
18
31
  private static registered: Map<string, DiscoveredModel> = new Map();
32
+ /** tableName -> public-writes flag (default secure); mirrors php's `$this->public`. */
33
+ private static publicWrites: Map<string, boolean> = new Map();
19
34
 
20
35
  /**
21
36
  * Register a model for auto-CRUD.
37
+ *
38
+ * @param options.public If true, the generated write routes are OPEN (no auth).
39
+ * Default (false) keeps them secure-by-default, matching the framework's write gate.
22
40
  */
23
- static register(model: DiscoveredModel, prefix: string = "/api"): void {
41
+ static register(model: DiscoveredModel, prefix: string = "/api", options: AutoCrudOptions = {}): void {
24
42
  const tableName = model.definition.tableName;
25
43
  if (!tableName) {
26
44
  throw new Error(`AutoCrud: model has no tableName set.`);
27
45
  }
28
46
  AutoCrud.registered.set(tableName, model);
47
+ AutoCrud.publicWrites.set(tableName, options.public === true);
29
48
  }
30
49
 
31
50
  /**
32
51
  * Discover models from the provided array and register them.
33
52
  * (In Node.js, models are discovered by the server and passed in.)
34
53
  */
35
- static discover(discoveredModels: DiscoveredModel[], prefix: string = "/api"): string[] {
54
+ static discover(discoveredModels: DiscoveredModel[], prefix: string = "/api", options: AutoCrudOptions = {}): string[] {
36
55
  const names: string[] = [];
37
56
  for (const model of discoveredModels) {
38
- AutoCrud.register(model, prefix);
57
+ AutoCrud.register(model, prefix, options);
39
58
  names.push(model.definition.tableName);
40
59
  }
41
60
  return names;
@@ -53,14 +72,20 @@ export class AutoCrud {
53
72
  */
54
73
  static clear(): void {
55
74
  AutoCrud.registered.clear();
75
+ AutoCrud.publicWrites.clear();
56
76
  }
57
77
 
58
78
  /**
59
- * Generate route definitions for all registered models.
79
+ * Generate route definitions for all registered models, honouring each
80
+ * model's per-table `public` flag (set at register/discover time).
60
81
  */
61
82
  static generateRoutes(): RouteDefinition[] {
62
- const models = Array.from(AutoCrud.registered.values());
63
- return generateCrudRoutes(models);
83
+ const routes: RouteDefinition[] = [];
84
+ for (const [tableName, model] of AutoCrud.registered) {
85
+ const isPublic = AutoCrud.publicWrites.get(tableName) === true;
86
+ routes.push(...generateCrudRoutes([model], { public: isPublic }));
87
+ }
88
+ return routes;
64
89
  }
65
90
  }
66
91
 
@@ -80,9 +105,18 @@ export function crudEligibleModels(models: DiscoveredModel[]): DiscoveredModel[]
80
105
  /**
81
106
  * Generate CRUD route definitions for the given models.
82
107
  * (Standalone function for backward compatibility.)
108
+ *
109
+ * @param options.public When true, the generated write routes (POST/PUT/DELETE)
110
+ * opt OUT of the router's secure-by-default write gate (`secure: false`) — the
111
+ * cross-backend escape hatch (parity with python `public=True` / php `$public`).
112
+ * Default (false) leaves `secure` unset so the router gates writes (secure:true).
113
+ * GET routes are unaffected (reads are already public).
83
114
  */
84
- export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[] {
115
+ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudOptions = {}): RouteDefinition[] {
85
116
  const routes: RouteDefinition[] = [];
117
+ // Only writes are gated; `public` flips them open. Spread this into POST/PUT/
118
+ // DELETE defs so the default path leaves `secure` unset (router → secure:true).
119
+ const writeSecurity = options.public === true ? { secure: false as const } : {};
86
120
 
87
121
  for (const { definition } of models) {
88
122
  const { tableName, fields, softDelete, tableFilter, fieldMapping } = definition;
@@ -167,10 +201,11 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
167
201
  },
168
202
  });
169
203
 
170
- // POST /api/{table} -- Create
204
+ // POST /api/{table} -- Create (secure-by-default; secure:false only when public)
171
205
  routes.push({
172
206
  method: "POST",
173
207
  pattern: basePath,
208
+ ...writeSecurity,
174
209
  meta: {
175
210
  summary: `Create ${tableName}`,
176
211
  tags: [tableName],
@@ -228,10 +263,11 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
228
263
  },
229
264
  });
230
265
 
231
- // PUT /api/{table}/:id -- Update
266
+ // PUT /api/{table}/:id -- Update (secure-by-default; secure:false only when public)
232
267
  routes.push({
233
268
  method: "PUT",
234
269
  pattern: `${basePath}/{id}`,
270
+ ...writeSecurity,
235
271
  meta: {
236
272
  summary: `Update ${tableName}`,
237
273
  tags: [tableName],
@@ -275,10 +311,11 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
275
311
  },
276
312
  });
277
313
 
278
- // DELETE /api/{table}/:id -- Delete (soft or hard)
314
+ // DELETE /api/{table}/:id -- Delete (secure-by-default; secure:false only when public)
279
315
  routes.push({
280
316
  method: "DELETE",
281
317
  pattern: `${basePath}/{id}`,
318
+ ...writeSecurity,
282
319
  meta: {
283
320
  summary: `Delete ${tableName}`,
284
321
  tags: [tableName],
@@ -47,6 +47,7 @@ export {
47
47
  } from "./migration.js";
48
48
  export type { MigrationResult, MigrationStatus } from "./migration.js";
49
49
  export { AutoCrud, generateCrudRoutes, crudEligibleModels } from "./autoCrud.js";
50
+ export type { AutoCrudOptions } from "./autoCrud.js";
50
51
  export { buildQuery, parseQueryString } from "./query.js";
51
52
  export { validate } from "./validation.js";
52
53
  export type { ValidationError } from "./validation.js";