natureco-cli 5.45.0 → 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,17 @@
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
+
5
16
  ## [5.45.0] - 2026-07-08 — "MEMORY: Urðr lint + fallback search entegre edildi"
6
17
 
7
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "natureco-cli",
3
- "version": "5.45.0",
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"
@@ -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
  }
@@ -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');
@@ -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 };