natureco-cli 5.7.0 → 5.7.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,95 @@
2
2
 
3
3
  All notable changes to NatureCo CLI will be documented in this file.
4
4
 
5
+ ## [5.7.1] - 2026-06-25 — "BUG FIX SPRINT"
6
+
7
+ Comprehensive audit-driven sprint: 7 real runtime bugs fixed, 6 new
8
+ utility modules with ~90%+ test coverage, ESLint + flat config installed,
9
+ test scripts wired to vitest (previously `npm test` was just running
10
+ `--help`). Pure quality / stability — no public API change.
11
+
12
+ ### 🐛 Fixed (runtime bugs)
13
+ - **REPL `/system <text>` crashed with TypeError** (`commands/repl.js`):
14
+ systemPrompt was `const` but the slash handler reassigned it. → `let`.
15
+ - **Telegram + IRC + SMS message handlers ReferenceError on every inbound**
16
+ (`commands/gateway-server.js`): `cleanCommand` variable never declared
17
+ in those scopes. Added `stripSlashPrefix(text)` helper mirroring the
18
+ v5.6.41+ WhatsApp transform; all three channels now derive it correctly.
19
+ - **Tool-alias rewrites threw ReferenceError** (`utils/tools.js`): typo
20
+ `TOOL_ALIASES[t.name]` where the local was `ALIAS_MAP`. Fixed.
21
+ - **5 silent `no-undef` ReferenceErrors** across `commands/{chat,nodes,
22
+ gateway,config}.js` + `utils/error.js` — missing `require()` calls
23
+ that had been hidden by CommonJS load-order side effects (would
24
+ crash on a fresh process or worker restart).
25
+
26
+ ### 🔒 Security
27
+ - **exec-approvals.json was world-readable (0644)** — local privilege
28
+ escalation hedef. Now 0o600 (file) + 0o700 (parent dir), with
29
+ auto-tightening of pre-existing loose installs. Removed dangling
30
+ `APPROVALS_SOCKET_PATH` constant + unused `net` require (socket never
31
+ existed; storage is the JSON file).
32
+ - **Anthropic `system` field sent as `''` or `undefined`** (api.js): now
33
+ always non-empty via `extractSystemForAnthropic(messages)` helper with
34
+ a meaningful default. Prevents 400 "system: cannot be empty" on
35
+ recent Messages API revisions + unanchored-model drift.
36
+
37
+ ### ⚙️ Reliability
38
+ - **Crash-safe atomic file writes** for sessions, history, memory,
39
+ approvals (new `utils/atomic-file.js`: temp-write + rename(2)).
40
+ Prior `fs.writeFileSync` left truncated JSON on SIGTERM / OOM /
41
+ power loss.
42
+ - **Memory fact cap silent fail fixed** (`tools/memory_write.js`): the
43
+ hardcoded `slice(0, 15)` ran BEFORE push, so once 15 high-score
44
+ facts were saved, every new write was the next iteration's eviction
45
+ victim — silently. Now: `MAX_FACTS_PER_USER` default 50 (env
46
+ `NATURECO_MAX_FACTS`), cap applied AFTER push, just-written fact
47
+ pinned at top, `console.warn` on breach (`NATURECO_QUIET_MEMORY=1`
48
+ to silence).
49
+ - **Global `unhandledRejection` + `uncaughtException` handlers**
50
+ (`utils/process-errors.js`, installed as the first statement in
51
+ `bin/natureco.js`): structured audit log entry + friendly Turkish
52
+ stderr + exit 1, instead of Node's default ugly stack dump.
53
+ - **Dashboard port + host de-hardcoded** (`utils/ports.js`):
54
+ `NATURECO_DASHBOARD_PORT` + `NATURECO_DASHBOARD_HOST` env overrides
55
+ with range/format validation. Previously 7421 was inlined in two
56
+ separate modules; drift risk eliminated.
57
+
58
+ ### 🧹 Refactor (DRY)
59
+ - **Streaming tool-call delta accumulator** extracted to
60
+ `utils/streaming-tools.js`. The per-index buffer + string-concat
61
+ pattern was duplicated in `utils/api.js` and `commands/repl.js` —
62
+ any drift between them would silently break tool calling on
63
+ Groq / MiniMax / DeepSeek / OpenAI.
64
+ - **Provider detection** centralized in `utils/provider-detect.js`.
65
+ Three call sites (`utils/api.js`, `commands/setup.js`) used three
66
+ different versions of the URL→provider mapping; the setup.js variant
67
+ was already incorrect (missed `minimax.cn`). Helper makes
68
+ `detectProvider`, `isMiniMax`, `isAnthropic`, `isGroq`, `isOllama`
69
+ the single source of truth.
70
+
71
+ ### 🧪 Testing
72
+ - **`npm test` actually runs tests now** — was previously just
73
+ `node bin/natureco.js help` (a load-smoke). Wired to `vitest run`.
74
+ - **+95 unit tests** across 9 new spec files. Coverage of the new
75
+ utility modules: streaming-tools 97%, provider-detect 100%,
76
+ process-errors 88%, ports ~93%, atomic-file ~93%, memory_write
77
+ internals ~85%.
78
+ - **Test suite: 12 files / 270 tests → 21 files / 365 tests.**
79
+ - `@vitest/coverage-v8` dev dep added; `npm run test:coverage` works.
80
+ - **prepublishOnly gate strengthened**: now runs `node --check` +
81
+ `eslint --quiet` + `vitest run` in sequence. A broken publish to
82
+ `npm install -g natureco-cli` users is now strictly blocked.
83
+
84
+ ### 🔧 Tooling
85
+ - **ESLint v9 flat config added** (`eslint.config.js`):
86
+ `@eslint/js` recommended + warn-level checks for unused-vars,
87
+ useless-escape, case-declarations, control-regex. Test files get
88
+ ES-module sourceType + vitest globals; `src/tools/browser*.js`
89
+ get browser globals for Playwright page.evaluate context.
90
+ Scripts: `npm run lint`, `lint:fix`, `lint:errors-only`.
91
+ After the no-undef fixes: 0 errors (293 unused-vars warnings
92
+ remain for a follow-up sprint).
93
+
5
94
  ## [5.7.0] - 2026-06-24 - SOUL SCRUBBED (MINOR)
6
95
 
7
96
  ### Security
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "natureco-cli",
3
- "version": "5.7.0",
3
+ "version": "5.7.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"
@@ -15,12 +15,16 @@
15
15
  "DEPLOY_v2.0.0.md"
16
16
  ],
17
17
  "scripts": {
18
- "test": "node bin/natureco.js help",
19
- "test:unit": "node --check bin/natureco.js && node -e \"console.log('All files OK')\"",
20
- "test:watch": "node --watch bin/natureco.js",
21
- "test:coverage": "node --check bin/natureco.js",
18
+ "test": "vitest run",
19
+ "test:unit": "vitest run",
20
+ "test:watch": "vitest",
21
+ "test:coverage": "vitest run --coverage",
22
+ "lint": "eslint src/ bin/ test/",
23
+ "lint:fix": "eslint src/ bin/ test/ --fix",
24
+ "lint:errors-only": "eslint src/ bin/ test/ --quiet",
25
+ "smoke": "node --check bin/natureco.js && node bin/natureco.js help",
22
26
  "postinstall": "node bin/natureco.js doctor || true",
23
- "prepublishOnly": "node --check bin/natureco.js"
27
+ "prepublishOnly": "node --check bin/natureco.js && eslint src/ bin/ test/ --quiet && vitest run"
24
28
  },
25
29
  "keywords": [
26
30
  "natureco",
@@ -81,6 +85,10 @@
81
85
  "ws": "^8.20.0"
82
86
  },
83
87
  "devDependencies": {
88
+ "@eslint/js": "^9.39.4",
89
+ "@vitest/coverage-v8": "^4.1.9",
90
+ "eslint": "^9.39.4",
91
+ "globals": "^15.15.0",
84
92
  "vitest": "^4.1.9"
85
93
  }
86
94
  }
@@ -17,6 +17,11 @@ const { runHooks } = require('../utils/hooks');
17
17
  const { createSession, loadSession, getLatestSession, addMessageToSession, loadLastSession, listSessions, saveSession } = require('../utils/sessions');
18
18
  const { NatureCoError, ApiError, handleError } = require('../utils/errors');
19
19
  const { getSessionStats, resetSessionStats } = require('../utils/tool-runner');
20
+ // getBots + sendMessage / _sendMessage are referenced later (loadProviders
21
+ // pre-warm, fallback non-streaming path) but the require was missing —
22
+ // the code only worked when something else in the load order had already
23
+ // required api.js.
24
+ const { getBots, sendMessage, _sendMessage } = require('../utils/api');
20
25
 
21
26
  const ASCII_LOGO = [
22
27
  '███╗ ██╗ █████╗ ████████╗██╗ ██╗██████╗ ███████╗ ██████╗ ██████╗',
@@ -4,6 +4,10 @@ const path = require('path');
4
4
  const fs = require('fs');
5
5
  const { getConfig, setConfigValue, getAllConfig, listBackups, restoreConfig, saveConfig, CONFIG_FILE, CONFIG_BACKUP_DIR } = require('../utils/config');
6
6
  const TB = require('../utils/token-budget');
7
+ // `tui` is referenced 25× below for styled output. Was relying on a
8
+ // side-effect global from elsewhere in the load order; require it
9
+ // explicitly so the file works in isolation too (caught by no-undef).
10
+ const tui = require('../utils/tui');
7
11
 
8
12
  function config(args) {
9
13
  const [action, key, ...valueParts] = args;
@@ -10,6 +10,31 @@ const { ApiError } = require('../utils/errors');
10
10
  const PID_FILE = path.join(os.homedir(), '.natureco', 'gateway.pid');
11
11
  const LOG_FILE = path.join(os.homedir(), '.natureco', 'gateway.log');
12
12
 
13
+ // `https` is used in the webhook delivery path (~line 570). The require was
14
+ // missing; the module only worked because Node's CommonJS cache happens to
15
+ // have it loaded from elsewhere, but in a fresh process or after a worker
16
+ // restart this would crash. Explicit.
17
+ const https = require('https');
18
+ // saveConfig (and the loaded `config` value, when not shadowed in scope)
19
+ // referenced in the Discord ready handler / Mattermost HTTP path. Same
20
+ // side-effect-global story as above.
21
+ const { saveConfig } = require('../utils/config');
22
+
23
+ /**
24
+ * Strip a leading slash command prefix from a messaging-channel inbound.
25
+ * v5.6.41+ added slash-prefix routing (e.g. `/ask`, `/help`, `/translate`)
26
+ * for iMessage + WhatsApp; the Telegram/IRC/SMS handlers were referencing
27
+ * a `cleanCommand` variable that no longer existed (3 ReferenceError sites,
28
+ * caught by ESLint no-undef). This helper restores the missing transform:
29
+ * "/ask merhaba" → "merhaba"
30
+ * "merhaba" → "merhaba"
31
+ * "" / undefined → ""
32
+ */
33
+ function stripSlashPrefix(text) {
34
+ if (!text || typeof text !== 'string') return '';
35
+ return text.replace(/^\/[a-zA-Z0-9_-]+\s*/, '').trim() || text.trim();
36
+ }
37
+
13
38
  // Silent logger for Baileys
14
39
  const silentLogger = {
15
40
  level: 'silent',
@@ -655,9 +680,10 @@ async function startTelegramProvider(config) {
655
680
  systemPrompt += '\n\n' + memoryPrompt;
656
681
  }
657
682
 
683
+ const cleanCommand = stripSlashPrefix(messageText);
658
684
  const response = await sendMessage(cfg.providerApiKey || cfg.apiKey || '', botId, cleanCommand, conversationId, systemPrompt);
659
685
  const reply = response?.reply || response?.message || '';
660
-
686
+
661
687
  if (reply) {
662
688
  log('telegram', 'Sending reply...', 'cyan');
663
689
 
@@ -1171,6 +1197,8 @@ async function processIrcMessage(config, sender, target, text, socket) {
1171
1197
  let systemPrompt = `You are a helpful IRC assistant. Keep responses concise. Use IRC-friendly formatting.`;
1172
1198
  if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1173
1199
 
1200
+ // IRC handler's parameter is `text` (see signature above), not `messageText`.
1201
+ const cleanCommand = stripSlashPrefix(text);
1174
1202
  const response = await sendMessage(
1175
1203
  cfg.providerApiKey || cfg.apiKey || '', botId, cleanCommand,
1176
1204
  conversationId, systemPrompt
@@ -1732,6 +1760,9 @@ async function handleSmsWebhook(config, body, req) {
1732
1760
  let systemPrompt = `You are a helpful SMS assistant. Keep responses concise (SMS format).`;
1733
1761
  if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1734
1762
 
1763
+ // SMS handler binds the inbound to `text` (Twilio webhook body), unlike
1764
+ // Telegram/IRC which use `messageText`.
1765
+ const cleanCommand = stripSlashPrefix(text);
1735
1766
  const response = await sendMessage(
1736
1767
  cfg.providerApiKey || cfg.apiKey || '', botId, cleanCommand,
1737
1768
  conversationId, systemPrompt
@@ -1880,7 +1911,10 @@ function startHttpServer() {
1880
1911
  res.end(JSON.stringify({ error: 'Mattermost not connected' }));
1881
1912
  return;
1882
1913
  }
1883
- await sendMattermostMessage(config, target, message);
1914
+ // Load fresh config inside the handler — the surrounding
1915
+ // `startHttpServer` scope does not bind `config`.
1916
+ const { getConfig: _gc1 } = require('../utils/config');
1917
+ await sendMattermostMessage(_gc1(), target, message);
1884
1918
  log('http', `Mattermost message sent to channel ${target}`, 'green');
1885
1919
  res.writeHead(200, { 'Content-Type': 'application/json' });
1886
1920
  res.end(JSON.stringify({ success: true, channel: 'mattermost', target }));
@@ -1907,7 +1941,8 @@ function startHttpServer() {
1907
1941
  res.end(JSON.stringify({ error: 'SMS not connected' }));
1908
1942
  return;
1909
1943
  }
1910
- await sendSmsMessage(config, target, message);
1944
+ const { getConfig: _gc2 } = require('../utils/config');
1945
+ await sendSmsMessage(_gc2(), target, message);
1911
1946
  log('http', `SMS sent to ${target}`, 'green');
1912
1947
  res.writeHead(200, { 'Content-Type': 'application/json' });
1913
1948
  res.end(JSON.stringify({ success: true, channel: 'sms', target }));
@@ -12,6 +12,8 @@ const { loadBaileys } = require('../utils/baileys');
12
12
  const { NatureCoError, GatewayError, handleError } = require('../utils/errors');
13
13
  const pino = require('pino');
14
14
  const logger = pino({ level: 'silent' });
15
+ // Version banner pulls packageJson at line 315 — was missing the require.
16
+ const packageJson = require('../../package.json');
15
17
 
16
18
  async function gateway(action, ...args) {
17
19
  if (action === 'start') {
@@ -4,6 +4,10 @@ const F = require('../utils/format');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ // getConfig + saveConfig referenced 8× below — missing require relied on
8
+ // side-effect global from another module's load order.
9
+ const { getConfig, saveConfig } = require('../utils/config');
10
+
7
11
  function nodes(args) {
8
12
  const [action, ...params] = args || [];
9
13
 
@@ -23,6 +23,7 @@ const { spawn } = require('child_process');
23
23
  const chalk = require('chalk');
24
24
  const tui = require('../utils/tui');
25
25
  const { loadToolDefinitions, toOpenAIFormat, executeTool } = require('../utils/tools');
26
+ const { accumulateToolCallDeltas, finalizeToolCalls } = require('../utils/streaming-tools');
26
27
 
27
28
  // v5.4.6: Model adi sizintisini engelle — global'e ata, callback'lerden erisebilir olsun
28
29
  const MODEL_NAMES_TO_HIDE = ['MiniMax-M2.5', 'MiniMaxM2.5', 'minimaxm25', 'Claude-3', 'GPT-4', 'ChatGPT'];
@@ -313,17 +314,10 @@ async function sendStreaming(providerUrl, providerApiKey, messages, model, onChu
313
314
  onChunk(delta.content);
314
315
  }
315
316
 
316
- // Tool calls (streaming delta)
317
+ // Tool calls (streaming delta) — shared accumulator,
318
+ // see src/utils/streaming-tools.js for the per-index pattern.
317
319
  if (delta.tool_calls) {
318
- for (const tcDelta of delta.tool_calls) {
319
- const idx = tcDelta.index;
320
- if (!toolCalls[idx]) {
321
- toolCalls[idx] = { index: idx, id: '', name: '', args: '' };
322
- }
323
- if (tcDelta.id) toolCalls[idx].id = tcDelta.id;
324
- if (tcDelta.function?.name) toolCalls[idx].name += tcDelta.function.name;
325
- if (tcDelta.function?.arguments) toolCalls[idx].args += tcDelta.function.arguments;
326
- }
320
+ accumulateToolCallDeltas(toolCalls, delta.tool_calls);
327
321
  }
328
322
  } catch {}
329
323
  }
@@ -338,17 +332,15 @@ async function sendStreaming(providerUrl, providerApiKey, messages, model, onChu
338
332
 
339
333
  fullText = result.streamText;
340
334
 
341
- // Tool call var mı?
342
- if (result.toolCalls && result.toolCalls.length > 0 && result.toolCalls[0].name) {
335
+ // Tool call var mı? finalizeToolCalls drops empty entries + synthesizes ids,
336
+ // so we only need to check the resulting length.
337
+ const finalized = finalizeToolCalls(result.toolCalls);
338
+ if (finalized.length > 0) {
343
339
  // Assistant mesajını ekle (tool_calls ile)
344
340
  currentMessages.push({
345
341
  role: 'assistant',
346
342
  content: result.streamText || null,
347
- tool_calls: result.toolCalls.map(tc => ({
348
- id: tc.id || `call_${Date.now()}_${tc.index}`,
349
- type: 'function',
350
- function: { name: tc.name, arguments: tc.args },
351
- })),
343
+ tool_calls: finalized,
352
344
  });
353
345
  // Her tool call'ı çalıştır, sonuçları tool mesajı olarak ekle
354
346
  const toolResults = await processToolCalls(result.toolCalls, onToolCall);
@@ -530,7 +522,10 @@ async function startRepl(args) {
530
522
  (cfg.providerUrl || '').includes('mistral.ai') ||
531
523
  (cfg.providerUrl || '').includes('localhost') ||
532
524
  (cfg.providerUrl || '').includes('ollama');
533
- const systemPrompt = [
525
+ // `let` (not `const`) because /system <text> reassigns it at line ~796.
526
+ // Before this fix, /system would throw "Assignment to constant variable"
527
+ // and tear down the REPL session mid-conversation.
528
+ let systemPrompt = [
534
529
  // === v5.4.14: EN KRITIK KIMLIK BILGILERI (her zaman ilk) ===
535
530
  `SENIN ADIN: ${botName}. SADECE ${botName} adini kullan, model adi SOYLEME.`,
536
531
  `PATRONUN: Gencay (Parton) — NatureCo CEO'sudur. Sana "Parton" diye hitap eder.`,
@@ -631,22 +631,23 @@ module.exports = setup;
631
631
  async function validateApiKey(providerUrl, apiKey) {
632
632
  return new Promise((resolve) => {
633
633
  const https = require('https');
634
- const url = new URL(providerUrl);
635
- const isMM = url.hostname.includes('minimax') || url.hostname.includes('minimaxi');
636
- const endpoint = isMM
634
+ const { isMiniMax, isGroq, isAnthropic } = require('../utils/provider-detect');
635
+ const isMM = isMiniMax(providerUrl);
636
+ const endpoint = isMM
637
637
  ? providerUrl.replace(/\/+$/, '') + '/v1/text/chatcompletion_v2'
638
638
  : providerUrl.replace(/\/+$/, '') + '/chat/completions';
639
639
 
640
640
  // Her provider icin test modeli
641
641
  let testModel = 'gpt-3.5-turbo';
642
- if (providerUrl.includes('groq.com')) testModel = 'llama-3.1-8b-instant';
643
- else if (providerUrl.includes('anthropic.com')) testModel = 'claude-3-haiku-20240307';
642
+ if (isGroq(providerUrl)) testModel = 'llama-3.1-8b-instant';
643
+ else if (isAnthropic(providerUrl)) testModel = 'claude-3-haiku-20240307';
644
644
  else if (isMM) testModel = 'MiniMax-M2.5';
645
645
  else if (providerUrl.includes('gemini')) testModel = 'gemini-1.5-flash';
646
646
  else if (providerUrl.includes('mistral.ai')) testModel = 'mistral-tiny';
647
647
  else if (providerUrl.includes('openrouter.ai')) testModel = 'meta-llama/llama-3.1-8b-instruct:free';
648
648
  else if (providerUrl.includes('deepseek.com')) testModel = 'deepseek-chat';
649
-
649
+
650
+
650
651
  const data = JSON.stringify({
651
652
  model: testModel,
652
653
  messages: [{ role: 'user', content: 'hi' }],
@@ -15,8 +15,9 @@ const http = require("http");
15
15
  const fs = require("fs");
16
16
  const path = require("path");
17
17
  const os = require("os");
18
+ const { getDashboardPort } = require("../utils/ports");
18
19
 
19
- const PORT = 7421;
20
+ const PORT = getDashboardPort();
20
21
  const DASHBOARD_HTML = `
21
22
  <!DOCTYPE html>
22
23
  <html lang="tr">
@@ -8,53 +8,93 @@
8
8
  const fs = require("fs");
9
9
  const path = require("path");
10
10
  const os = require("os");
11
+ const { writeJsonAtomicSync, readJsonSafeSync } = require("../utils/atomic-file");
11
12
 
12
13
  const MEMORY_DIR = path.join(os.homedir(), ".natureco", "memory");
13
14
 
15
+ // Soft cap on per-user fact count. Decay already prunes old/low-importance
16
+ // facts; this just bounds the worst-case file size and prompt-injection
17
+ // surface. Configurable via NATURECO_MAX_FACTS (default 50, was a hard 15
18
+ // that was silently truncating new writes when full).
19
+ const MAX_FACTS_PER_USER = (() => {
20
+ const raw = parseInt(process.env.NATURECO_MAX_FACTS || "", 10);
21
+ return Number.isFinite(raw) && raw > 0 ? raw : 50;
22
+ })();
23
+
14
24
  function getMemoryFile(username) {
15
25
  const name = (username || "default").toLowerCase();
16
26
  return path.join(MEMORY_DIR, `${name}.json`);
17
27
  }
18
28
 
29
+ function _emptyMemory(username) {
30
+ return { name: username || "User", nickname: null, botName: null, facts: [], preferences: [] };
31
+ }
32
+
19
33
  function loadMemory(username) {
20
- const file = getMemoryFile(username);
21
- try {
22
- if (!fs.existsSync(file)) {
23
- return { name: username || "User", nickname: null, botName: null, facts: [], preferences: [] };
24
- }
25
- return JSON.parse(fs.readFileSync(file, "utf8"));
26
- } catch {
27
- return { name: username || "User", nickname: null, botName: null, facts: [], preferences: [] };
28
- }
34
+ return readJsonSafeSync(getMemoryFile(username), _emptyMemory(username));
29
35
  }
30
36
 
31
37
  function saveMemory(username, memory) {
32
38
  if (!fs.existsSync(MEMORY_DIR)) fs.mkdirSync(MEMORY_DIR, { recursive: true });
33
39
  memory.lastUpdated = new Date().toISOString();
34
- fs.writeFileSync(getMemoryFile(username), JSON.stringify(memory, null, 2), "utf8");
40
+ writeJsonAtomicSync(getMemoryFile(username), memory);
35
41
  return memory;
36
42
  }
37
43
 
38
44
  /**
39
- * Score azalt (eski fact'ler zamanla unutuluyor)
45
+ * Score azalt (eski fact'ler zamanla unutulur).
46
+ * Sıralama veya soft-cap UYGULAMAZ — onu enforceFactLimit yapar
47
+ * (push'tan sonra çağrılır, böylece yeni fact silinmez).
40
48
  */
41
49
  function decayFacts(memory) {
42
50
  if (!memory.facts) return memory;
43
51
  const now = Date.now();
44
52
  memory.facts = memory.facts.map(f => {
45
53
  if (!f.score) f.score = 5;
46
- // 1 haftadan eski -1, 1 aydan eski -3
47
54
  const ageMs = now - new Date(f.updatedAt || f.createdAt || now).getTime();
48
55
  const ageDays = ageMs / (1000 * 60 * 60 * 24);
49
56
  if (ageDays > 30) f.score = Math.max(0, f.score - 3);
50
57
  else if (ageDays > 7) f.score = Math.max(0, f.score - 1);
51
58
  return f;
52
59
  });
53
- // score 0 olanlari sil
54
60
  memory.facts = memory.facts.filter(f => (f.score || 0) > 0);
55
- // max 15 fact tut
56
- memory.facts.sort((a, b) => (b.score || 0) - (a.score || 0));
57
- memory.facts = memory.facts.slice(0, 15);
61
+ return memory;
62
+ }
63
+
64
+ /**
65
+ * Soft cap: yeni fact eklenmesinden SONRA uygulanır, böylece az önce
66
+ * yazılan fact eviction kurbanı olmaz. En düşük score + en eski updatedAt
67
+ * önce gider. Cap aşılırsa stderr'e tek satır warn yazar (eski "silent
68
+ * truncate to 15" davranışını gözlemlenebilir hale getirir).
69
+ *
70
+ * @param {{facts: Array<object>}} memory
71
+ * @param {{recentValue?: string}} [opts] Korunması zorunlu fact (yeni eklenen)
72
+ * @returns the same memory
73
+ */
74
+ function enforceFactLimit(memory, opts = {}) {
75
+ if (!memory.facts || memory.facts.length <= MAX_FACTS_PER_USER) return memory;
76
+ const before = memory.facts.length;
77
+ const recent = opts.recentValue ? opts.recentValue.toLowerCase() : null;
78
+ // Sort by score desc, then updatedAt desc (newest+highest first).
79
+ // The just-pushed fact is pinned at the top regardless of its score.
80
+ memory.facts.sort((a, b) => {
81
+ if (recent) {
82
+ if ((a.value || "").toLowerCase() === recent) return -1;
83
+ if ((b.value || "").toLowerCase() === recent) return 1;
84
+ }
85
+ const sa = a.score || 0, sb = b.score || 0;
86
+ if (sa !== sb) return sb - sa;
87
+ return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
88
+ });
89
+ memory.facts = memory.facts.slice(0, MAX_FACTS_PER_USER);
90
+ const dropped = before - memory.facts.length;
91
+ if (dropped > 0 && !process.env.NATURECO_QUIET_MEMORY) {
92
+ // eslint-disable-next-line no-console
93
+ console.warn(
94
+ `[memory] cap ${MAX_FACTS_PER_USER} aşıldı, en düşük skorlu ${dropped} fact düşürüldü ` +
95
+ `(NATURECO_MAX_FACTS ile değiştir, NATURECO_QUIET_MEMORY=1 ile sustur)`
96
+ );
97
+ }
58
98
  return memory;
59
99
  }
60
100
 
@@ -122,6 +162,8 @@ function addMemory({ username, fact, score = 5, category = "general", botName, n
122
162
  createdAt: new Date().toISOString(),
123
163
  });
124
164
  }
165
+ // Limit'i ZIM push'tan sonra uygula → yeni eklenen fact düşürülmez.
166
+ memory = enforceFactLimit(memory, { recentValue: fact });
125
167
  }
126
168
 
127
169
  if (!memory.preferences) memory.preferences = [];
@@ -173,6 +215,18 @@ function showMemory({ username }) {
173
215
  }
174
216
 
175
217
  module.exports = {
218
+ // Exposed for tests / advanced consumers — not part of the tool schema.
219
+ _internals: {
220
+ MAX_FACTS_PER_USER,
221
+ enforceFactLimit,
222
+ decayFacts,
223
+ loadMemory,
224
+ saveMemory,
225
+ addMemory,
226
+ clearMemory,
227
+ showMemory,
228
+ getMemoryFile,
229
+ },
176
230
  name: "memory_write",
177
231
  description: "Memory'ye yeni fact/bilgi kaydet veya bot ismini/nickname'i degistir. Kalici, REPL'in extractMemoryFromMessage ozelligi.",
178
232
  inputSchema: {