natureco-cli 5.45.0 → 5.46.0

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,28 @@
2
2
 
3
3
  All notable changes to NatureCo CLI will be documented in this file.
4
4
 
5
+ ## [5.46.0] - 2026-07-08 — "MEMORY: otomatik hijyen — yazma-anı dedup/çelişki uyarısı + oturum-sonu ipucu"
6
+
7
+ ### Added
8
+ - **Otomatik hafıza hijyeni (lint artık yalnız manuel değil).** `natureco memory lint` güçlüydü ama çoğu kullanıcı hiç çalıştırmaz; hafıza sessizce yinelenen/çelişen kayıtlarla bozulurdu. İki gürültüsüz otomatik katman eklendi:
9
+ - **Yazma-anı (`memory_tree append`, ajanın her kaydında).** Yeni yaprak aynı dala eklenirken mevcut yapraklarla Jaccard benzerliğine bakılır (Urðr lint mantığı, LLM'siz): **(a)** çok-benzer (≥%85) → **tekrar EKLENMEZ** (`deduped:true` + not; bloat önlenir, veri kaybı yok çünkü zaten var); **(b)** aynı konu farklı değer (%50–85) → eklenir **ama** sonuca `warning` düşülür (çelişki; hangi değerin doğru olduğuna karar veremeyiz, veriyi kaybetmeyiz → ajan/kullanıcı uzlaştırır, gerekirse `memory_tree remove`). Uyarı `buildFeedback` üzerinden ajana ulaşır (E2E doğrulandı).
10
+ - **Oturum-sonu ipucu.** `natureco chat` kapanışında hafızada olası yinelenen/çelişen kayıt varsa tek satır hatırlatma: `💡 Hafızada N olası yinelenen/çelişen kayıt — "natureco memory lint" ile gözden geçir.` Sadece bulgu varsa yazılır; hata asla oturumu bozmaz.
11
+ - 4 yeni regresyon testi (dedup atlama + tek kopya, çelişki uyarısı + ikisi de saklanır, alakasız temiz, tool-düzeyi Türkçe recall). **600 test yeşil** (597 + 3 skip).
12
+
13
+ ### Notes
14
+ - Duplicate eşiği bilinçli olarak yüksek (%85) — yalnızca neredeyse-birebir kayıtlar atlanır; "sunucu ip 10.0.0.5" → "10.0.0.9" gibi gerçek güncellemeler çelişki sayılır (atlanmaz, uyarılır), böylece meşru bir değişiklik asla kaybolmaz.
15
+
16
+ ## [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"
17
+
18
+ ### Fixed
19
+ - **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.
20
+ - **`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.
21
+
22
+ ### Added
23
+ - `src/utils/tr-text.js` (`foldTr`, `foldIncludes`) — Türkçe-güvenli case folding, tek kaynak.
24
+ - Lint bulgularında **dal (branch) bağlamı** gösterilir (`(## Projeler)` / `(## Kararlar)`) — kullanıcı çelişen kaydın hangisinin nerede olduğunu görür.
25
+ - 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).
26
+
5
27
  ## [5.45.0] - 2026-07-08 — "MEMORY: Urðr lint + fallback search entegre edildi"
6
28
 
7
29
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "natureco-cli",
3
- "version": "5.45.0",
3
+ "version": "5.46.0",
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"
@@ -435,19 +435,19 @@ function lintMemoryCmd(user) {
435
435
  console.log(chalk.green('\n ✓ Temiz — yinelenen veya çelişen kayıt yok.\n'));
436
436
  return;
437
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
+ };
438
444
  if (dups.length) {
439
445
  console.log(chalk.yellow(`\n ⚠ ${dups.length} olası YİNELENEN (aynı bilgi iki kez):`));
440
- for (const f of dups.slice(0, 10)) {
441
- console.log(` ${chalk.gray(`(%${Math.round(f.sim * 100)})`)} ${f.a}`);
442
- console.log(` ${chalk.gray(' ≈')} ${f.b}${f.file ? chalk.gray(' [' + f.file + ']') : ''}`);
443
- }
446
+ for (const f of dups.slice(0, 10)) printFinding(f, '≈');
444
447
  }
445
448
  if (conflicts.length) {
446
449
  console.log(chalk.red(`\n ⚠ ${conflicts.length} olası ÇELİŞKİ (aynı konu, farklı değer):`));
447
- for (const f of conflicts.slice(0, 10)) {
448
- console.log(` ${chalk.gray(`(%${Math.round(f.sim * 100)})`)} ${f.a}`);
449
- console.log(` ${chalk.gray(' ↔')} ${f.b}${f.file ? chalk.gray(' [' + f.file + ']') : ''}`);
450
- }
450
+ for (const f of conflicts.slice(0, 10)) printFinding(f, '↔');
451
451
  }
452
452
  console.log(chalk.gray(`\n Öneri: eskiyen/yanlış kaydı düzeltin — "natureco memory clear" veya elle. Tek doğru kalsın.\n`));
453
453
  }
@@ -1243,6 +1243,16 @@ async function startRepl(args) {
1243
1243
  console.log(chalk.gray(` 🎯 ${persistResult.preferencesAdded} yeni tercih kaydedildi`));
1244
1244
  }
1245
1245
 
1246
+ // v5.46: oturum-sonu hafıza-sağlığı ipucu (gürültüsüz). Yinelenen/çelişen kayıt varsa
1247
+ // tek satır hatırlat — kullanıcı çoğu zaman "natureco memory lint"i hiç çalıştırmaz,
1248
+ // hafıza sessizce bozulur. Sadece bulgu varsa yazılır; hata asla oturumu bozmaz.
1249
+ try {
1250
+ const { lintUser } = require('../utils/memory-lint');
1251
+ const { flatFindings, treeFindings } = lintUser(cfg.userName);
1252
+ const n = (flatFindings.length + treeFindings.length);
1253
+ if (n > 0) console.log(chalk.gray(` 💡 Hafızada ${n} olası yinelenen/çelişen kayıt — "natureco memory lint" ile gözden geçir.`));
1254
+ } catch {}
1255
+
1246
1256
  const sessId = saveSession(messages, {
1247
1257
  provider: providerUrl, model, user: cfg.userName,
1248
1258
  bot: memory.botName, factCount: memory.facts?.length || 0,
@@ -17,6 +17,8 @@
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');
21
+ const { tokens, jaccard } = require('../utils/memory-lint');
20
22
 
21
23
  const ROOTS = [
22
24
  { id: '1-kisisel', title: 'Kişisel & Tercihler', branches: ['Kimlik', 'Tercihler', 'İletişim Kalıpları'] },
@@ -91,7 +93,10 @@ function buildDigest(username, maxChars = 2600) {
91
93
 
92
94
  function search(username, query) {
93
95
  ensureTree(username);
94
- const q = String(query || '').toLowerCase().trim();
96
+ // v5.45.1: Türkçe-güvenli eşleşme (foldTr). Eski `line.toLowerCase().includes(q)` locale
97
+ // duyarsızdı → "İstanbul" .toLowerCase() = "i̇stanbul" olur ve "istanbul" sorgusuyla EŞLEŞMEZDİ;
98
+ // her büyük-harfli Türkçe kelime (İzmir, İş, İletişim…) canlı recall'da sessizce kaçıyordu.
99
+ const q = foldTr(query).trim();
95
100
  if (!q) return [];
96
101
  const hits = [];
97
102
  for (const r of ROOTS) {
@@ -99,7 +104,7 @@ function search(username, query) {
99
104
  let branch = '';
100
105
  for (const line of txt.split('\n')) {
101
106
  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()}`);
107
+ else if (line.trim() && !line.startsWith('#') && foldTr(line).includes(q)) hits.push(`${r.id}/${branch}: ${line.trim()}`);
103
108
  }
104
109
  }
105
110
  return hits;
@@ -121,26 +126,58 @@ function getPending(username) {
121
126
  function remove(username, root, query) {
122
127
  ensureTree(username);
123
128
  const r = resolveRoot(root || '3-kararlar');
124
- const q = String(query || '').toLowerCase().trim();
129
+ const q = foldTr(query).trim(); // v5.45.1: Türkçe-güvenli (bkz: search)
125
130
  if (!q) return { success: false, error: 'query gerekli' };
126
131
  const txt = readSafe(username, r.id);
127
132
  const kept = [];
128
133
  let removed = 0;
129
134
  for (const line of txt.split('\n')) {
130
- if (/^\s*-\s+/.test(line) && line.toLowerCase().includes(q)) { removed++; continue; }
135
+ if (/^\s*-\s+/.test(line) && foldTr(line).includes(q)) { removed++; continue; }
131
136
  kept.push(line);
132
137
  }
133
138
  if (removed) fs.writeFileSync(rootPath(username, r.id), kept.join('\n'), 'utf8');
134
139
  return { success: true, removed, root: r.id };
135
140
  }
136
141
 
142
+ // Belirli bir daldaki mevcut yaprakları döndür (yazma-anı hijyen kontrolü için). Türkçe-güvenli dal eşleşmesi.
143
+ function leavesInBranch(txt, branchName) {
144
+ const target = foldTr(branchName).trim();
145
+ const out = [];
146
+ let cur = null;
147
+ for (const line of txt.split('\n')) {
148
+ const h = line.match(/^##\s+(.+?)\s*$/);
149
+ if (h) { cur = foldTr(h[1]).trim(); continue; }
150
+ if (cur === target && /^\s*-\s+\S/.test(line)) out.push(line.trim().replace(/^-\s*/, ''));
151
+ }
152
+ return out;
153
+ }
154
+
137
155
  function append(username, root, branch, content) {
138
156
  ensureTree(username);
139
157
  const r = resolveRoot(root);
140
158
  const p = rootPath(username, r.id);
141
159
  const br = String(branch || 'Genel').trim();
142
- const leaf = '- ' + String(content).replace(/\s+/g, ' ').trim();
160
+ const cleaned = String(content).replace(/\s+/g, ' ').trim();
161
+ const leaf = '- ' + cleaned;
143
162
  let txt = fs.readFileSync(p, 'utf8');
163
+
164
+ // v5.46: yazma-anı hijyen (Urðr lint mantığı, LLM'siz). Aynı dala eklenen yaprağı mevcutlarla
165
+ // Jaccard ile karşılaştır: (a) çok-benzer (≥0.85) → TEKRAR EKLEME (bloat önle, veri kaybı yok
166
+ // çünkü zaten var); (b) aynı konu farklı değer (0.5–0.85) → EKLE ama UYAR (çelişki; hangi
167
+ // değerin doğru olduğuna karar veremeyiz, veriyi kaybetmeyiz → ajan/kullanıcı uzlaştırır).
168
+ let best = { sim: 0, leaf: null };
169
+ try {
170
+ const nt = tokens(cleaned);
171
+ for (const ex of leavesInBranch(txt, br)) {
172
+ const s = jaccard(nt, tokens(ex));
173
+ if (s > best.sim) best = { sim: s, leaf: ex };
174
+ }
175
+ } catch {}
176
+ if (best.sim >= 0.85) {
177
+ return { success: true, deduped: true, root: r.id, branch: br,
178
+ note: `Zaten çok benzer bir kayıt var (%${Math.round(best.sim * 100)}: "${best.leaf}") — tekrar eklenmedi.` };
179
+ }
180
+
144
181
  const bRe = new RegExp(`^##[ \\t]+${br.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[ \\t]*$`, 'mi');
145
182
  const m = txt.match(bRe);
146
183
  if (m) {
@@ -150,7 +187,11 @@ function append(username, root, branch, content) {
150
187
  txt = txt.trimEnd() + `\n\n## ${br}\n${leaf}\n`;
151
188
  }
152
189
  fs.writeFileSync(p, txt, 'utf8');
153
- return { success: true, root: r.id, branch: br, saved: leaf };
190
+ const result = { success: true, root: r.id, branch: br, saved: leaf };
191
+ if (best.sim >= 0.5) {
192
+ result.warning = `Aynı konuda farklı bir kayıt var (%${Math.round(best.sim * 100)}): "${best.leaf}". İkisi de saklandı; doğru olan kalsın, gerekirse memory_tree remove ile eskisini sil.`;
193
+ }
194
+ return result;
154
195
  }
155
196
 
156
197
  module.exports = {
@@ -184,5 +225,5 @@ module.exports = {
184
225
  return { success: false, error: 'bilinmeyen action: ' + p.action + ' (index|read|search|append|remove)' };
185
226
  } catch (e) { return { success: false, error: e.message }; }
186
227
  },
187
- _internal: { ensureTree, buildIndex, buildDigest, readRoot, search, append, getPending, remove, treeDir, rootPath, ROOTS },
228
+ _internal: { ensureTree, buildIndex, buildDigest, readRoot, search, append, getPending, remove, leavesInBranch, treeDir, rootPath, ROOTS },
188
229
  };
@@ -10,22 +10,30 @@
10
10
  *
11
11
  * Flat memory: ~/.natureco/memory/<user>.json (facts: [{value, ...}])
12
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.
13
16
  */
14
17
 
15
18
  const fs = require('fs');
16
19
  const path = require('path');
17
20
  const os = require('os');
21
+ const { foldTr } = require('./tr-text');
18
22
 
19
23
  const MEMORY_DIR = path.join(os.homedir(), '.natureco', 'memory');
20
24
  const DUP = 0.85;
21
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;
22
28
 
23
29
  const STOP = new Set(['ve', 'ile', 'için', 'icin', 'bir', 'bu', 'da', 'de', 'the', 'and', 'for',
24
30
  'kullanici', 'kullanıcı', 'kullanicinin', 'kullanıcının', 'benim', 'onun', 'çok', 'cok']);
25
31
 
26
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).
27
35
  return new Set(
28
- String(text || '').toLowerCase()
36
+ foldTr(text)
29
37
  .replace(/[*_`|]/g, ' ')
30
38
  .replace(/\b\d{2}\.\d{2}\.\d{4}\b/g, ' ')
31
39
  .split(/[^a-z0-9çğıöşü-]+/i)
@@ -42,22 +50,48 @@ function jaccard(a, b) {
42
50
 
43
51
  /**
44
52
  * Lint an array of fact objects/strings. Returns findings, most-similar first.
45
- * @returns {Array<{level:'duplicate'|'conflict', sim:number, a:string, b:string}>}
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}>}
46
56
  */
47
57
  function lintFacts(facts) {
48
- const vals = (facts || []).map((f) => (f && f.value != null ? f.value : f)).filter((v) => typeof v === 'string' && v.trim());
49
- const toks = vals.map(tokens);
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));
50
62
  const findings = [];
51
- for (let i = 0; i < vals.length; i++) {
52
- for (let j = i + 1; j < vals.length; j++) {
63
+ for (let i = 0; i < items.length; i++) {
64
+ for (let j = i + 1; j < items.length; j++) {
53
65
  const sim = jaccard(toks[i], toks[j]);
54
- if (sim >= DUP) findings.push({ level: 'duplicate', sim, a: vals[i], b: vals[j] });
55
- else if (sim >= CONFLICT) findings.push({ level: 'conflict', sim, a: vals[i], b: vals[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
+ });
56
75
  }
57
76
  }
58
77
  return findings.sort((x, y) => y.sim - x.sim);
59
78
  }
60
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
+
61
95
  /** Parse a tree root file into branches → leaves and lint for duplicates/conflicts within it. */
62
96
  function lintTreeFile(file) {
63
97
  let content;
@@ -68,8 +102,8 @@ function lintTreeFile(file) {
68
102
  const h = line.match(/^##\s+(.+?)\s*$/);
69
103
  if (h) { branch = h[1]; continue; }
70
104
  const t = line.trim();
71
- if (!t || t.startsWith('<!--') || t.startsWith('#') || t === '---' || /^_no entries yet\._$/i.test(t) || t.startsWith('>')) continue;
72
- leaves.push({ value: t, branch });
105
+ if (!isLeaf(t)) continue;
106
+ leaves.push({ value: leafText(t), branch });
73
107
  }
74
108
  return lintFacts(leaves).map((f) => ({ ...f, file: path.basename(file) }));
75
109
  }
@@ -89,7 +123,7 @@ function lintUser(user) {
89
123
  const treeFindings = [];
90
124
  try {
91
125
  for (const f of fs.readdirSync(treeDir)) {
92
- if (/^(?:(?:root|kök|kok)-)?\d[-_].*\.md$/i.test(f)) treeFindings.push(...lintTreeFile(path.join(treeDir, f)));
126
+ if (ROOT_RE.test(f)) treeFindings.push(...lintTreeFile(path.join(treeDir, f)));
93
127
  }
94
128
  } catch {}
95
129
 
@@ -100,17 +134,22 @@ function lintUser(user) {
100
134
  * v5.45: Branch-aware fallback search over a user's memory tree (Urðr search.mjs port).
101
135
  * The hierarchical 4-step lookup is primary; this is the safety net so a wrong-root guess
102
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).
103
144
  * @returns {Array<{file, branch, text}>}
104
145
  */
105
146
  function searchTree(user, query, opts = {}) {
106
147
  const max = opts.max || 25;
107
- const treeDir = path.join(MEMORY_DIR, 'tree', (user || 'default').toLowerCase());
108
- if (!query || !String(query).trim()) return [];
109
- let re;
110
- try { re = new RegExp(query, 'i'); }
111
- catch { re = new RegExp(String(query).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); }
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 [];
112
151
  let files;
113
- try { files = fs.readdirSync(treeDir).filter((f) => /^(?:(?:root|kök|kok)-)?\d[-_].*\.md$/i.test(f)); }
152
+ try { files = fs.readdirSync(treeDir).filter((f) => ROOT_RE.test(f)); }
114
153
  catch { return []; }
115
154
  const out = [];
116
155
  for (const file of files.sort()) {
@@ -121,8 +160,12 @@ function searchTree(user, query, opts = {}) {
121
160
  const h = line.match(/^##\s+(.+?)\s*$/);
122
161
  if (h) { branch = h[1]; continue; }
123
162
  const t = line.trim();
124
- if (!t || t.startsWith('<!--') || t.startsWith('#') || t === '---' || /^_no entries yet\._$/i.test(t) || t.startsWith('>')) continue;
125
- if (re.test(line)) { out.push({ file, branch, text: t.slice(0, 300) }); if (out.length >= max) return out; }
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
+ }
126
169
  }
127
170
  }
128
171
  return out;
@@ -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 };