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 +22 -0
- package/package.json +1 -1
- package/src/commands/memory-cmd.js +8 -8
- package/src/commands/repl.js +10 -0
- package/src/tools/memory_tree.js +48 -7
- package/src/utils/memory-lint.js +62 -19
- package/src/utils/tr-text.js +50 -0
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.
|
|
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
|
}
|
package/src/commands/repl.js
CHANGED
|
@@ -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,
|
package/src/tools/memory_tree.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/src/utils/memory-lint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
|
49
|
-
|
|
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 <
|
|
52
|
-
for (let j = i + 1; 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
|
|
55
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
109
|
-
|
|
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) =>
|
|
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 (!
|
|
125
|
-
|
|
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 };
|