natureco-cli 5.44.1 → 5.45.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to NatureCo CLI will be documented in this file.
4
4
 
5
+ ## [5.45.1] - 2026-07-08 — "FIX: Türkçe İ/i recall hatası — büyük-harfli her Türkçe kelime sessizce kaçıyordu"
6
+
7
+ ### Fixed
8
+ - **Ajanın canlı hafıza recall'ı büyük-harfli Türkçe kelimeleri BULAMIYORDU (kritik, doğrulandı).** `memory_tree` search/remove eşleşmeyi `line.toLowerCase().includes(q)` ile yapıyordu; ama JS'in `toLowerCase()`'i locale-duyarsız: `"İstanbul".toLowerCase()` → `"i̇stanbul"` (ASCII i + U+0307 BİRLEŞİK NOKTA) olur ve `"istanbul"` sorgusuyla **EŞLEŞMEZ**. Sonuç: her konuşmada İstanbul, İzmir, İş, İletişim gibi büyük-harfli Türkçe kelimeler recall'da görünmez şekilde kaçıyordu — Türkçe-öncelikli bir üründe milyonlarca kullanıcı için sessiz veri kaybı. Artık ortak `src/utils/tr-text.js` **`foldTr`** helper'ı dört Türkçe i-varyantını (İ/I/ı/i → i) tek forma indiriyor; `{İstanbul, istanbul, ISTANBUL, ıstanbul}` hepsi `"istanbul"` sorgusuyla eşleşiyor, İngilizce bozulmuyor (`FILE`→`file`), anlam taşıyan ş/ç/ğ/ö/ü korunuyor (`şık`≠`sık`). Hem ajan recall'ı (`memory_tree`) hem insan CLI araması (`memory-lint searchTree`) aynı folding'i kullanıyor.
9
+ - **`memory search` regex-özel-karakter tuzağı.** Fallback arama `new RegExp(query, 'i')` kullanıyordu; `"proje kod adı (v2)"` gibi doğal bir sorguda parantezler yakalama-grubu sayılıp literal metinle **eşleşmiyordu**. Artık regex yok: sorgu boşluklardan kelimelere ayrılıp **AND** mantığıyla literal aranıyor — özel karakterler `()[]*?` literal alınır, çok-kelimeli sorgular daha isabetli.
10
+
11
+ ### Added
12
+ - `src/utils/tr-text.js` (`foldTr`, `foldIncludes`) — Türkçe-güvenli case folding, tek kaynak.
13
+ - Lint bulgularında **dal (branch) bağlamı** gösterilir (`(## Projeler)` / `(## Kararlar)`) — kullanıcı çelişen kaydın hangisinin nerede olduğunu görür.
14
+ - 22 yeni regresyon testi (İ/i folding, regex-özel-karakter literal, çok-kelime AND, branch koruma, dosya-yok güvenliği). **596 test yeşil** (593 + 3 skip).
15
+
16
+ ## [5.45.0] - 2026-07-08 — "MEMORY: Urðr lint + fallback search entegre edildi"
17
+
18
+ ### Added
19
+ - **`natureco memory lint`** — Urðr standardından (natureco-official/urdr) türetilen, LLM'siz sağlık denetimi. Düz hafızada (`<user>.json`) ve ağaç hafızada (`tree/<user>/`) **YİNELENEN** (aynı bilgi iki kez) ve **ÇELİŞEN** (aynı konu, farklı değer — ör. iki farklı favori renk ya da iki farklı proje kod adı) kayıtları Jaccard benzerliğiyle yakalar. Bu, recall'ın "yanlış hatırlanan değeri" döndürmesinin kök nedeni. Gerçek kullanıcı hafızasında test edildi (çelişki yakalandı).
20
+ - **`natureco memory search`'e ağaç fallback'i** — düz hafızada bulunamazsa, ağaç hafızada branch-aware tam tarama yapar (Urðr `search.mjs` portu). Yanlış-kök tahmini yüzünden bilgi "erişilemez" kalmaz. LLM'siz, cross-platform. Sonuçlar `dosya › ## dal › yaprak` biçiminde.
21
+ - `src/utils/memory-lint.js` — `lintFacts`/`lintUser`/`searchTree` (5 regresyon testi). 576 test yeşil.
22
+
23
+ Bu, Urðr'nin kanıtlanmış hafıza-güvenilirlik araçlarını NatureCo'nun gerçek memory-tree'sine bağlar; native root isimleri (`0-index.md`, `1-kisisel.md` …) zaten tanınıyor.
24
+
5
25
  ## [5.44.1] - 2026-07-08 — "MEMORY: açık 'hatırla' komutu HAM kaydedilir (kayıp önlenir)"
6
26
 
7
27
  ### Added
package/bin/natureco.js CHANGED
@@ -130,7 +130,7 @@ ${chalk.yellow('💻 Geliştirici Araçları')}
130
130
 
131
131
  ${chalk.yellow('📊 Yönetim & Sistem')}
132
132
  ${chalk.cyan('dashboard')} Web kontrol paneli (open|status|url)
133
- ${chalk.cyan('memory')} Hafıza yönetimi (status|list|search|show|clear|index|export|import|semantic|wiki)
133
+ ${chalk.cyan('memory')} Hafıza yönetimi (status|list|search|lint|show|clear|index|export|import|semantic|wiki)
134
134
  ${chalk.cyan('logs')} Log yönetimi (tail|show|search|clear|path)
135
135
  ${chalk.cyan('status')} Sistem durumu (run|simple|usage)
136
136
  ${chalk.cyan('plugins')} Plugin yönetimi (list|install|uninstall|enable|disable|info|update|search|doctor|registry|marketplace)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "natureco-cli",
3
- "version": "5.44.1",
3
+ "version": "5.45.1",
4
4
  "description": "OpenClaw'dan daha güvenli, daha hızlı, daha ucuz AI agent CLI. Multi-agent, self-evolving skills, audit log, maliyet optimizasyonu ve NatureCo platform-native.",
5
5
  "bin": {
6
6
  "natureco": "bin/natureco.js"
@@ -19,6 +19,7 @@ async function memoryCmd(args) {
19
19
  if (action === 'index') return indexMemory();
20
20
  if (action === 'export') return exportMemoryCmd(params[0], params[1]);
21
21
  if (action === 'import') return importMemoryCmd(params[0], params[1]);
22
+ if (action === 'lint') return lintMemoryCmd(params[0]);
22
23
  if (action === 'semantic') return semanticSearchCmd(params.join(' '));
23
24
  // Wiki pages
24
25
  if (action === 'wiki') return wikiCmd(params[0], params.slice(1));
@@ -141,14 +142,24 @@ function searchMemory(query) {
141
142
  });
142
143
  });
143
144
 
144
- console.log(chalk.cyan.bold(`\n "${query}" için ${results.length} sonuç\n`));
145
- if (results.length === 0) {
146
- console.log(chalk.gray(' Sonuç bulunamadı.\n'));
147
- return;
148
- }
145
+ console.log(chalk.cyan.bold(`\n "${query}" için ${results.length} sonuç (düz hafıza)\n`));
149
146
  results.forEach(r => {
150
147
  console.log(chalk.white(` [${r.bot}] `) + chalk.gray(`${r.field}: `) + chalk.white(r.value));
151
148
  });
149
+
150
+ // v5.45: ağaç hafızada branch-aware FALLBACK (Urðr) — düz hafızada bulunamasa bile
151
+ // bilgi "erişilemez" kalmasın (yanlış-kök tahmini kurtarma ağı). LLM'siz.
152
+ try {
153
+ const { searchTree } = require('../utils/memory-lint');
154
+ let u; try { u = require('../utils/config').getConfig().userName; } catch {}
155
+ const treeHits = searchTree(u || 'default', query);
156
+ if (treeHits.length) {
157
+ console.log(chalk.cyan(`\n Ağaç hafızada ${treeHits.length} sonuç:`));
158
+ treeHits.slice(0, 15).forEach(h => console.log(chalk.gray(` ${h.file} › ## ${h.branch} › `) + chalk.white(h.text)));
159
+ } else if (results.length === 0) {
160
+ console.log(chalk.gray(' Sonuç bulunamadı (düz + ağaç hafıza).'));
161
+ }
162
+ } catch { if (results.length === 0) console.log(chalk.gray(' Sonuç bulunamadı.')); }
152
163
  console.log('');
153
164
  }
154
165
 
@@ -404,4 +415,41 @@ function semanticSearchMemory(query, botId) {
404
415
  return results;
405
416
  }
406
417
 
418
+ // v5.45: memory lint — Urðr-derived duplicate/conflict audit (flat + tree).
419
+ // Catches "same fact stored twice" and "same subject, different value" (the drift that
420
+ // makes recall return the wrong remembered value, e.g. two different project code names).
421
+ function lintMemoryCmd(user) {
422
+ const { lintUser } = require('../utils/memory-lint');
423
+ let u = user;
424
+ if (!u) { try { u = require('../utils/config').getConfig().userName; } catch {} }
425
+ const { flatFile, flatCount, flatFindings, treeFindings } = lintUser(u || 'default');
426
+
427
+ console.log(chalk.cyan(`\n 🧠 Memory Lint · ${u || 'default'}\n`) + chalk.gray(' ' + '─'.repeat(52)));
428
+ console.log(chalk.gray(` flat: ${flatCount} fact · ${path.basename(flatFile)}`));
429
+
430
+ const all = [...flatFindings, ...treeFindings];
431
+ const dups = all.filter((f) => f.level === 'duplicate');
432
+ const conflicts = all.filter((f) => f.level === 'conflict');
433
+
434
+ if (all.length === 0) {
435
+ console.log(chalk.green('\n ✓ Temiz — yinelenen veya çelişen kayıt yok.\n'));
436
+ return;
437
+ }
438
+ // Tree bulgularında dal (branch) bağlamını da göster ki kullanıcı "hangisi nerede" bilsin.
439
+ const br = (b) => (b ? chalk.gray(` (## ${b})`) : '');
440
+ const printFinding = (f, sym) => {
441
+ console.log(` ${chalk.gray(`(%${Math.round(f.sim * 100)})`)} ${f.a}${br(f.aBranch)}`);
442
+ console.log(` ${chalk.gray(' ' + sym)} ${f.b}${br(f.bBranch)}${f.file ? chalk.gray(' [' + f.file + ']') : ''}`);
443
+ };
444
+ if (dups.length) {
445
+ console.log(chalk.yellow(`\n ⚠ ${dups.length} olası YİNELENEN (aynı bilgi iki kez):`));
446
+ for (const f of dups.slice(0, 10)) printFinding(f, '≈');
447
+ }
448
+ if (conflicts.length) {
449
+ console.log(chalk.red(`\n ⚠ ${conflicts.length} olası ÇELİŞKİ (aynı konu, farklı değer):`));
450
+ for (const f of conflicts.slice(0, 10)) printFinding(f, '↔');
451
+ }
452
+ console.log(chalk.gray(`\n Öneri: eskiyen/yanlış kaydı düzeltin — "natureco memory clear" veya elle. Tek doğru kalsın.\n`));
453
+ }
454
+
407
455
  module.exports = memoryCmd;
@@ -17,6 +17,7 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const os = require('os');
20
+ const { foldTr } = require('../utils/tr-text');
20
21
 
21
22
  const ROOTS = [
22
23
  { id: '1-kisisel', title: 'Kişisel & Tercihler', branches: ['Kimlik', 'Tercihler', 'İletişim Kalıpları'] },
@@ -91,7 +92,10 @@ function buildDigest(username, maxChars = 2600) {
91
92
 
92
93
  function search(username, query) {
93
94
  ensureTree(username);
94
- const q = String(query || '').toLowerCase().trim();
95
+ // v5.45.1: Türkçe-güvenli eşleşme (foldTr). Eski `line.toLowerCase().includes(q)` locale
96
+ // duyarsızdı → "İstanbul" .toLowerCase() = "i̇stanbul" olur ve "istanbul" sorgusuyla EŞLEŞMEZDİ;
97
+ // her büyük-harfli Türkçe kelime (İzmir, İş, İletişim…) canlı recall'da sessizce kaçıyordu.
98
+ const q = foldTr(query).trim();
95
99
  if (!q) return [];
96
100
  const hits = [];
97
101
  for (const r of ROOTS) {
@@ -99,7 +103,7 @@ function search(username, query) {
99
103
  let branch = '';
100
104
  for (const line of txt.split('\n')) {
101
105
  if (line.startsWith('## ')) branch = line.slice(3).trim();
102
- else if (line.trim() && !line.startsWith('#') && line.toLowerCase().includes(q)) hits.push(`${r.id}/${branch}: ${line.trim()}`);
106
+ else if (line.trim() && !line.startsWith('#') && foldTr(line).includes(q)) hits.push(`${r.id}/${branch}: ${line.trim()}`);
103
107
  }
104
108
  }
105
109
  return hits;
@@ -121,13 +125,13 @@ function getPending(username) {
121
125
  function remove(username, root, query) {
122
126
  ensureTree(username);
123
127
  const r = resolveRoot(root || '3-kararlar');
124
- const q = String(query || '').toLowerCase().trim();
128
+ const q = foldTr(query).trim(); // v5.45.1: Türkçe-güvenli (bkz: search)
125
129
  if (!q) return { success: false, error: 'query gerekli' };
126
130
  const txt = readSafe(username, r.id);
127
131
  const kept = [];
128
132
  let removed = 0;
129
133
  for (const line of txt.split('\n')) {
130
- if (/^\s*-\s+/.test(line) && line.toLowerCase().includes(q)) { removed++; continue; }
134
+ if (/^\s*-\s+/.test(line) && foldTr(line).includes(q)) { removed++; continue; }
131
135
  kept.push(line);
132
136
  }
133
137
  if (removed) fs.writeFileSync(rootPath(username, r.id), kept.join('\n'), 'utf8');
@@ -0,0 +1,174 @@
1
+ /**
2
+ * memory-lint — flat + tree memory health audit (Urðr-derived, LLM-free)
3
+ *
4
+ * Ported from the Urðr standard (natureco-official/urdr scripts/lint.mjs) into NatureCo's
5
+ * real memory layout so users are protected from the drift that erodes recall:
6
+ * - DUPLICATE facts (Jaccard ≥ 0.85) — the same thing stored twice, slightly reworded.
7
+ * - CONFLICTING facts (Jaccard 0.5–0.85) — same subject, different value, e.g.
8
+ * "favori rengi kırmızı" vs "Favori rengi mavidir", or two different project code names.
9
+ * This is exactly why recall can return the "wrong" remembered value.
10
+ *
11
+ * Flat memory: ~/.natureco/memory/<user>.json (facts: [{value, ...}])
12
+ * Tree memory: ~/.natureco/memory/tree/<user>/*.md (## branch → leaves)
13
+ *
14
+ * v5.45.1: matching is Turkish-aware (tr-text foldTr) and searchTree is regex-free so a
15
+ * natural query like "proje kod adı (v2)" is matched literally, not as a regex.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const { foldTr } = require('./tr-text');
22
+
23
+ const MEMORY_DIR = path.join(os.homedir(), '.natureco', 'memory');
24
+ const DUP = 0.85;
25
+ const CONFLICT = 0.5;
26
+ // Root files: Urðr "root-N-name.md" / "kök-N-…" and NatureCo native "N-name.md".
27
+ const ROOT_RE = /^(?:(?:root|kök|kok)-)?\d[-_].*\.md$/i;
28
+
29
+ const STOP = new Set(['ve', 'ile', 'için', 'icin', 'bir', 'bu', 'da', 'de', 'the', 'and', 'for',
30
+ 'kullanici', 'kullanıcı', 'kullanicinin', 'kullanıcının', 'benim', 'onun', 'çok', 'cok']);
31
+
32
+ function tokens(text) {
33
+ // foldTr lowercases and normalizes the four Turkish i-variants so "İstanbul" and
34
+ // "istanbul" tokenize identically (better duplicate/conflict detection for TR facts).
35
+ return new Set(
36
+ foldTr(text)
37
+ .replace(/[*_`|]/g, ' ')
38
+ .replace(/\b\d{2}\.\d{2}\.\d{4}\b/g, ' ')
39
+ .split(/[^a-z0-9çğıöşü-]+/i)
40
+ .filter((w) => w.length > 2 && !STOP.has(w))
41
+ );
42
+ }
43
+
44
+ function jaccard(a, b) {
45
+ if (a.size === 0 || b.size === 0) return 0;
46
+ let inter = 0;
47
+ for (const x of a) if (b.has(x)) inter++;
48
+ return inter / (a.size + b.size - inter);
49
+ }
50
+
51
+ /**
52
+ * Lint an array of fact objects/strings. Returns findings, most-similar first.
53
+ * Each fact may carry an optional `branch` (tree memory) which is propagated into the
54
+ * finding as aBranch/bBranch so the caller can tell the user WHERE each side lives.
55
+ * @returns {Array<{level:'duplicate'|'conflict', sim:number, a:string, b:string, aBranch?:string, bBranch?:string}>}
56
+ */
57
+ function lintFacts(facts) {
58
+ const items = (facts || [])
59
+ .map((f) => ({ value: f && f.value != null ? f.value : f, branch: f && f.branch }))
60
+ .filter((x) => typeof x.value === 'string' && x.value.trim());
61
+ const toks = items.map((x) => tokens(x.value));
62
+ const findings = [];
63
+ for (let i = 0; i < items.length; i++) {
64
+ for (let j = i + 1; j < items.length; j++) {
65
+ const sim = jaccard(toks[i], toks[j]);
66
+ if (sim < CONFLICT) continue;
67
+ findings.push({
68
+ level: sim >= DUP ? 'duplicate' : 'conflict',
69
+ sim,
70
+ a: items[i].value,
71
+ b: items[j].value,
72
+ aBranch: items[i].branch,
73
+ bBranch: items[j].branch,
74
+ });
75
+ }
76
+ }
77
+ return findings.sort((x, y) => y.sim - x.sim);
78
+ }
79
+
80
+ /** Is this a real leaf line (not a heading/comment/separator/placeholder/quote)? */
81
+ function isLeaf(trimmed) {
82
+ return !!trimmed
83
+ && !trimmed.startsWith('<!--')
84
+ && !trimmed.startsWith('#')
85
+ && trimmed !== '---'
86
+ && !/^_no entries yet\._$/i.test(trimmed)
87
+ && !trimmed.startsWith('>');
88
+ }
89
+
90
+ /** Strip a leading list marker ("- ", "* ", "+ ") for clean display/tokenization. */
91
+ function leafText(trimmed) {
92
+ return trimmed.replace(/^[-*+]\s+/, '');
93
+ }
94
+
95
+ /** Parse a tree root file into branches → leaves and lint for duplicates/conflicts within it. */
96
+ function lintTreeFile(file) {
97
+ let content;
98
+ try { content = fs.readFileSync(file, 'utf8'); } catch { return []; }
99
+ const leaves = [];
100
+ let branch = '(root)';
101
+ for (const line of content.split(/\r?\n/)) {
102
+ const h = line.match(/^##\s+(.+?)\s*$/);
103
+ if (h) { branch = h[1]; continue; }
104
+ const t = line.trim();
105
+ if (!isLeaf(t)) continue;
106
+ leaves.push({ value: leafText(t), branch });
107
+ }
108
+ return lintFacts(leaves).map((f) => ({ ...f, file: path.basename(file) }));
109
+ }
110
+
111
+ /** Full audit for a user: flat facts + every tree root file. */
112
+ function lintUser(user) {
113
+ const uname = (user || 'default').toLowerCase();
114
+ const flatFile = path.join(MEMORY_DIR, `${uname}.json`);
115
+ let flatFindings = [], flatCount = 0;
116
+ try {
117
+ const facts = (JSON.parse(fs.readFileSync(flatFile, 'utf8')).facts) || [];
118
+ flatCount = facts.length;
119
+ flatFindings = lintFacts(facts);
120
+ } catch {}
121
+
122
+ const treeDir = path.join(MEMORY_DIR, 'tree', uname);
123
+ const treeFindings = [];
124
+ try {
125
+ for (const f of fs.readdirSync(treeDir)) {
126
+ if (ROOT_RE.test(f)) treeFindings.push(...lintTreeFile(path.join(treeDir, f)));
127
+ }
128
+ } catch {}
129
+
130
+ return { flatFile, flatCount, flatFindings, treeFindings };
131
+ }
132
+
133
+ /**
134
+ * v5.45: Branch-aware fallback search over a user's memory tree (Urðr search.mjs port).
135
+ * The hierarchical 4-step lookup is primary; this is the safety net so a wrong-root guess
136
+ * never makes stored info read as "forgotten". LLM-free, cross-platform.
137
+ *
138
+ * v5.45.1: Turkish-aware (foldTr) and REGEX-FREE. The query is split into whitespace terms
139
+ * and a leaf matches only if it contains ALL terms (AND) — so multi-word queries are useful
140
+ * and special characters like ()[]*? are matched literally instead of breaking the search.
141
+ * @param {string} user
142
+ * @param {string} query
143
+ * @param {{max?:number, dir?:string}} opts dir overrides the tree directory (testing).
144
+ * @returns {Array<{file, branch, text}>}
145
+ */
146
+ function searchTree(user, query, opts = {}) {
147
+ const max = opts.max || 25;
148
+ const treeDir = opts.dir || path.join(MEMORY_DIR, 'tree', (user || 'default').toLowerCase());
149
+ const terms = String(query == null ? '' : query).split(/\s+/).map(foldTr).filter(Boolean);
150
+ if (!terms.length) return [];
151
+ let files;
152
+ try { files = fs.readdirSync(treeDir).filter((f) => ROOT_RE.test(f)); }
153
+ catch { return []; }
154
+ const out = [];
155
+ for (const file of files.sort()) {
156
+ let content;
157
+ try { content = fs.readFileSync(path.join(treeDir, file), 'utf8'); } catch { continue; }
158
+ let branch = '(root)';
159
+ for (const line of content.split(/\r?\n/)) {
160
+ const h = line.match(/^##\s+(.+?)\s*$/);
161
+ if (h) { branch = h[1]; continue; }
162
+ const t = line.trim();
163
+ if (!isLeaf(t)) continue;
164
+ const folded = foldTr(line);
165
+ if (terms.every((term) => folded.includes(term))) {
166
+ out.push({ file, branch, text: leafText(t).slice(0, 300) });
167
+ if (out.length >= max) return out;
168
+ }
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+
174
+ module.exports = { lintFacts, lintTreeFile, lintUser, searchTree, tokens, jaccard };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * tr-text — Turkish-aware text folding for case-insensitive, robust substring matching.
3
+ *
4
+ * WHY THIS EXISTS (real bug, verified):
5
+ * JavaScript's String.prototype.toLowerCase() is locale-INSENSITIVE. On the dotted
6
+ * capital "İ" (U+0130) it produces "i̇" (ASCII i + COMBINING DOT ABOVE U+0307), which
7
+ * does NOT equal a plain "i". So the naive `text.toLowerCase().includes(query)` used by
8
+ * memory recall silently MISSES every capitalized Turkish word:
9
+ * "İstanbul".toLowerCase() -> "i̇stanbul" (query "istanbul" → NO MATCH)
10
+ * "ISPARTA".toLowerCase() -> "isparta" (Turkish query "ısparta" → NO MATCH)
11
+ * For a Turkish-first product this breaks recall for İstanbul, İzmir, İş, İletişim, …
12
+ * on every conversation — invisible data loss for millions of users.
13
+ *
14
+ * THE FIX:
15
+ * Fold all four Turkish "i" letters — İ (dotted upper), I (dotless upper), ı (dotless
16
+ * lower), i (dotted lower) — to a single canonical 'i' BEFORE lowercasing. This makes
17
+ * {İstanbul, istanbul, ISTANBUL, ıstanbul} all match query "istanbul", while English
18
+ * stays intact ("FILE" -> "file", not the Turkish "fıle").
19
+ *
20
+ * We deliberately do NOT fold ş/ç/ğ/ö/ü — those carry meaning ("şık" elegant ≠ "sık"
21
+ * frequent), and collapsing them would create wrong matches. Only the notorious
22
+ * i-dot case-folding trap is normalized.
23
+ *
24
+ * Used by both the agent's live recall (memory_tree search/remove) and the human CLI
25
+ * search (memory-lint searchTree) so matching behaves identically everywhere.
26
+ */
27
+
28
+ /**
29
+ * Fold text for case-insensitive Turkish-safe comparison. Never throws.
30
+ * @param {*} s
31
+ * @returns {string}
32
+ */
33
+ function foldTr(s) {
34
+ return String(s == null ? '' : s)
35
+ .replace(/İ/g, 'i') // U+0130 dotted capital → i (avoids the U+0307 combining-dot trap)
36
+ .replace(/I/g, 'i') // U+0049 dotless capital → i
37
+ .replace(/ı/g, 'i') // U+0131 dotless lower → i
38
+ .toLowerCase();
39
+ }
40
+
41
+ /**
42
+ * Turkish-aware, case-insensitive "does haystack contain needle?". Substring — no regex,
43
+ * so query text with (), [], *, ? etc. is matched literally (never interpreted).
44
+ * @returns {boolean}
45
+ */
46
+ function foldIncludes(haystack, needle) {
47
+ return foldTr(haystack).includes(foldTr(needle));
48
+ }
49
+
50
+ module.exports = { foldTr, foldIncludes };