natureco-cli 5.7.0 → 5.7.2
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 +89 -0
- package/bin/natureco.js +0 -0
- package/package.json +14 -6
- package/src/commands/chat.js +5 -0
- package/src/commands/config.js +4 -0
- package/src/commands/gateway-server.js +38 -3
- package/src/commands/gateway.js +2 -0
- package/src/commands/nodes.js +4 -0
- package/src/commands/repl.js +19 -20
- package/src/commands/setup.js +7 -6
- package/src/tools/dashboard.js +2 -1
- package/src/tools/edit_file.js +143 -0
- package/src/tools/memory_write.js +70 -16
- package/src/tools/read_file.js +122 -63
- package/src/tools/todo_write.js +219 -52
- package/src/utils/api.js +47 -40
- package/src/utils/approvals.js +27 -12
- package/src/utils/atomic-file.js +91 -0
- package/src/utils/dashboard-server.js +3 -2
- package/src/utils/error.js +4 -0
- package/src/utils/history.js +6 -14
- package/src/utils/paste-safe-input.js +173 -0
- package/src/utils/ports.js +44 -0
- package/src/utils/process-errors.js +115 -0
- package/src/utils/provider-detect.js +78 -0
- package/src/utils/sessions.js +6 -13
- package/src/utils/streaming-tools.js +97 -0
- package/src/utils/tools.js +4 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
+
writeJsonAtomicSync(getMemoryFile(username), memory);
|
|
35
41
|
return memory;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/**
|
|
39
|
-
* Score azalt (eski fact'ler zamanla
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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: {
|
package/src/tools/read_file.js
CHANGED
|
@@ -1,75 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read_file — Read a file's contents.
|
|
3
|
+
*
|
|
4
|
+
* Backwards-compatible upgrade (v5.7.1): the old call shape
|
|
5
|
+
* `{ path }` still works and still returns `{success, path, content, size,
|
|
6
|
+
* truncated}` exactly as before. The new options add Claude Code-style
|
|
7
|
+
* pagination + line-numbered output:
|
|
8
|
+
*
|
|
9
|
+
* - `offset` (line number, 1-based): skip the first N-1 lines.
|
|
10
|
+
* - `limit` (line count): read at most this many lines.
|
|
11
|
+
* - `numbered` (bool): if true, prefix each line with its 1-based
|
|
12
|
+
* line number + a tab (matches `cat -n`); useful when the agent
|
|
13
|
+
* then needs to call edit_file and wants to cite exact lines.
|
|
14
|
+
*
|
|
15
|
+
* Other safety touches retained from the original:
|
|
16
|
+
* - File-existence + not-a-file checks.
|
|
17
|
+
* - 1 MB cap before falling into "show me 50 KB" mode (raised to
|
|
18
|
+
* 2 MB now that line-based pagination exists — the agent can ask
|
|
19
|
+
* for `{offset, limit}` instead).
|
|
20
|
+
* - Returns `{success: false, error}` rather than throwing.
|
|
21
|
+
*/
|
|
1
22
|
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
23
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
fs.closeSync(fd);
|
|
47
|
-
|
|
48
|
-
const content = '[Büyük dosya - ilk ~50KB gösteriliyor]\n' + buf.toString('utf8');
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
success: true,
|
|
52
|
-
path: filePath,
|
|
53
|
-
content,
|
|
54
|
-
size: stats.size,
|
|
55
|
-
truncated: true
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
60
|
-
|
|
24
|
+
const DEFAULT_LINE_LIMIT = 2000; // matches Claude Code's Read default
|
|
25
|
+
const HARD_BYTE_CAP = 2 * 1024 * 1024; // 2 MB — agent should paginate above this
|
|
26
|
+
const SOFT_BYTE_PREVIEW = 50_000; // bytes shown for the "file too large" preview
|
|
27
|
+
|
|
28
|
+
function _formatNumbered(text, startLine) {
|
|
29
|
+
const lines = text.split('\n');
|
|
30
|
+
let out = '';
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
out += String(startLine + i) + '\t' + lines[i];
|
|
33
|
+
if (i < lines.length - 1) out += '\n';
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readFile(params) {
|
|
39
|
+
try {
|
|
40
|
+
const { expandPath } = require('../utils/paths');
|
|
41
|
+
const filePath = expandPath(params.path);
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(filePath)) {
|
|
44
|
+
return { success: false, error: 'File does not exist' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stats = fs.statSync(filePath);
|
|
48
|
+
if (!stats.isFile()) {
|
|
49
|
+
return { success: false, error: 'Path is not a file' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const offset = Math.max(1, Number(params.offset) || 1);
|
|
53
|
+
const limit = params.limit !== undefined ? Math.max(1, Number(params.limit)) : null;
|
|
54
|
+
const numbered = !!params.numbered;
|
|
55
|
+
const wantsPagination = params.offset !== undefined || params.limit !== undefined;
|
|
56
|
+
|
|
57
|
+
// Large-file guard. With pagination the agent can read what it needs;
|
|
58
|
+
// without it we still show a useful preview (matches old behavior).
|
|
59
|
+
if (stats.size > HARD_BYTE_CAP && !wantsPagination) {
|
|
60
|
+
const fd = fs.openSync(filePath, 'r');
|
|
61
|
+
const buf = Buffer.alloc(SOFT_BYTE_PREVIEW);
|
|
62
|
+
fs.readSync(fd, buf, 0, SOFT_BYTE_PREVIEW, 0);
|
|
63
|
+
fs.closeSync(fd);
|
|
64
|
+
const preview = '[Büyük dosya — ilk ~50KB gösteriliyor; offset/limit ile sayfalayın]\n' + buf.toString('utf8');
|
|
61
65
|
return {
|
|
62
66
|
success: true,
|
|
63
67
|
path: filePath,
|
|
64
|
-
content,
|
|
68
|
+
content: preview,
|
|
65
69
|
size: stats.size,
|
|
66
|
-
truncated:
|
|
70
|
+
truncated: true,
|
|
67
71
|
};
|
|
68
|
-
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
75
|
+
|
|
76
|
+
if (!wantsPagination && !numbered) {
|
|
77
|
+
// Original return shape — keep byte-for-byte compatible.
|
|
69
78
|
return {
|
|
70
|
-
success:
|
|
71
|
-
|
|
79
|
+
success: true,
|
|
80
|
+
path: filePath,
|
|
81
|
+
content: raw,
|
|
82
|
+
size: stats.size,
|
|
83
|
+
truncated: false,
|
|
72
84
|
};
|
|
73
85
|
}
|
|
86
|
+
|
|
87
|
+
// Pagination + optional line numbers.
|
|
88
|
+
const allLines = raw.split('\n');
|
|
89
|
+
const totalLines = allLines.length;
|
|
90
|
+
const start = Math.min(offset, totalLines + 1) - 1; // to 0-based
|
|
91
|
+
const effectiveLimit = limit !== null ? limit : DEFAULT_LINE_LIMIT;
|
|
92
|
+
const end = Math.min(start + effectiveLimit, totalLines);
|
|
93
|
+
const slice = allLines.slice(start, end);
|
|
94
|
+
const sliceText = slice.join('\n');
|
|
95
|
+
const content = numbered ? _formatNumbered(sliceText, start + 1) : sliceText;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
path: filePath,
|
|
100
|
+
content,
|
|
101
|
+
size: stats.size,
|
|
102
|
+
truncated: end < totalLines,
|
|
103
|
+
total_lines: totalLines,
|
|
104
|
+
lines_returned: slice.length,
|
|
105
|
+
offset: start + 1,
|
|
106
|
+
limit: effectiveLimit,
|
|
107
|
+
numbered,
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { success: false, error: error.message };
|
|
74
111
|
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
name: 'read_file',
|
|
116
|
+
description:
|
|
117
|
+
'PRIMARY TOOL: Read a file. Supports Claude Code-style pagination ' +
|
|
118
|
+
'(offset + limit, 1-based line numbers) and optional `numbered` output ' +
|
|
119
|
+
'(prefixes each line with its number + tab, matching `cat -n`). ' +
|
|
120
|
+
'For listing directories use the filesystem tool. For ranges past 2000 ' +
|
|
121
|
+
'lines, call again with a new offset.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
path: { type: 'string', description: 'File path to read (absolute or ~-prefix)' },
|
|
126
|
+
offset: { type: 'integer', description: '1-based line number to start at (default 1)', minimum: 1 },
|
|
127
|
+
limit: { type: 'integer', description: `Maximum lines to return (default ${DEFAULT_LINE_LIMIT})`, minimum: 1 },
|
|
128
|
+
numbered: { type: 'boolean', description: 'Prefix each line with its 1-based line number + tab (cat -n format)', default: false },
|
|
129
|
+
},
|
|
130
|
+
required: ['path'],
|
|
131
|
+
},
|
|
132
|
+
execute: readFile,
|
|
133
|
+
_internals: { readFile, _formatNumbered },
|
|
75
134
|
};
|
package/src/tools/todo_write.js
CHANGED
|
@@ -1,88 +1,255 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* todo_write
|
|
2
|
+
* todo_write — Task / todo manager.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Upgrade (v5.7.1) modeled after Claude Code's TaskCreate/TaskUpdate/
|
|
5
|
+
* TaskList semantics so the agent can track multi-step work like
|
|
6
|
+
* `pending → in_progress → completed`, name an `activeForm` for the
|
|
7
|
+
* spinner, and express dependencies via `blockedBy` / `blocks`.
|
|
8
|
+
*
|
|
9
|
+
* Backwards compatibility: every old action (list/add/done/remove/
|
|
10
|
+
* clear) and the old `{content, priority, status: 'done'}` shape keeps
|
|
11
|
+
* working byte-for-byte — see the test file for the locked-in shape.
|
|
12
|
+
* The on-disk JSON gains new fields but old `{id, content, status,
|
|
13
|
+
* priority, createdAt, completedAt}` entries are still read correctly.
|
|
5
14
|
*/
|
|
6
15
|
|
|
7
|
-
const fs = require(
|
|
8
|
-
const path = require(
|
|
9
|
-
const os = require(
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const { writeJsonAtomicSync, readJsonSafeSync } = require('../utils/atomic-file');
|
|
20
|
+
|
|
21
|
+
const TODO_FILE = path.join(os.homedir(), '.natureco', 'todos.json');
|
|
10
22
|
|
|
11
|
-
const
|
|
23
|
+
const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
|
24
|
+
const VALID_PRIORITIES = new Set(['low', 'medium', 'high']);
|
|
12
25
|
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return JSON.parse(fs.readFileSync(TODO_FILE, "utf8"));
|
|
17
|
-
} catch { return []; }
|
|
26
|
+
function _loadTodos() {
|
|
27
|
+
const data = readJsonSafeSync(TODO_FILE, []);
|
|
28
|
+
return Array.isArray(data) ? data : [];
|
|
18
29
|
}
|
|
19
30
|
|
|
20
|
-
function
|
|
31
|
+
function _saveTodos(todos) {
|
|
21
32
|
fs.mkdirSync(path.dirname(TODO_FILE), { recursive: true });
|
|
22
|
-
|
|
33
|
+
writeJsonAtomicSync(TODO_FILE, todos);
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
function
|
|
36
|
+
function _genId() {
|
|
26
37
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
27
38
|
}
|
|
28
39
|
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
function _normalizeStatus(s) {
|
|
41
|
+
if (!s) return null;
|
|
42
|
+
// accept legacy 'done' as alias for 'completed'
|
|
43
|
+
if (s === 'done') return 'completed';
|
|
44
|
+
return VALID_STATUSES.has(s) ? s : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _normalizeTodo(t) {
|
|
48
|
+
// Migrate old shape on the fly so listing always returns a uniform
|
|
49
|
+
// structure even for entries written by v5.6 and earlier.
|
|
50
|
+
return {
|
|
51
|
+
id: t.id,
|
|
52
|
+
subject: t.subject || t.content || '(no subject)',
|
|
53
|
+
description: t.description || '',
|
|
54
|
+
activeForm: t.activeForm || t.subject || t.content || '',
|
|
55
|
+
status: _normalizeStatus(t.status) || 'pending',
|
|
56
|
+
priority: VALID_PRIORITIES.has(t.priority) ? t.priority : 'medium',
|
|
57
|
+
owner: t.owner || null,
|
|
58
|
+
blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy : [],
|
|
59
|
+
blocks: Array.isArray(t.blocks) ? t.blocks : [],
|
|
60
|
+
metadata: (t.metadata && typeof t.metadata === 'object') ? t.metadata : {},
|
|
61
|
+
createdAt: t.createdAt || new Date().toISOString(),
|
|
62
|
+
updatedAt: t.updatedAt || t.createdAt || new Date().toISOString(),
|
|
63
|
+
completedAt: t.completedAt || null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
31
66
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
function _isBlocked(todo, allTodos) {
|
|
68
|
+
if (!todo.blockedBy || todo.blockedBy.length === 0) return false;
|
|
69
|
+
const openIds = new Set(
|
|
70
|
+
allTodos.filter(t => t.status !== 'completed' && t.status !== 'deleted').map(t => t.id),
|
|
71
|
+
);
|
|
72
|
+
return todo.blockedBy.some(id => openIds.has(id));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function todoAction(params) {
|
|
76
|
+
const action = params.action || 'list';
|
|
77
|
+
let todos = _loadTodos().map(_normalizeTodo);
|
|
78
|
+
|
|
79
|
+
// ─────────────────── LIST / GET ───────────────────
|
|
80
|
+
if (action === 'list' || action === 'get') {
|
|
81
|
+
if (action === 'get') {
|
|
82
|
+
if (!params.id) return { success: false, error: 'id gerekli' };
|
|
83
|
+
const t = todos.find(x => x.id === params.id);
|
|
84
|
+
if (!t) return { success: false, error: `Todo bulunamadı: ${params.id}` };
|
|
85
|
+
return { success: true, todo: t };
|
|
86
|
+
}
|
|
87
|
+
// Filter by status if specified
|
|
88
|
+
const filtered = params.status
|
|
89
|
+
? todos.filter(t => t.status === _normalizeStatus(params.status))
|
|
90
|
+
: todos.filter(t => t.status !== 'deleted');
|
|
91
|
+
// Annotate with blockedBy state
|
|
92
|
+
const annotated = filtered.map(t => ({
|
|
93
|
+
...t,
|
|
94
|
+
currently_blocked: _isBlocked(t, todos),
|
|
95
|
+
}));
|
|
96
|
+
return {
|
|
97
|
+
success: true,
|
|
98
|
+
total: todos.filter(t => t.status !== 'deleted').length,
|
|
99
|
+
pending: todos.filter(t => t.status === 'pending').length,
|
|
100
|
+
in_progress: todos.filter(t => t.status === 'in_progress').length,
|
|
101
|
+
completed: todos.filter(t => t.status === 'completed').length,
|
|
102
|
+
todos: annotated,
|
|
103
|
+
};
|
|
36
104
|
}
|
|
37
105
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
106
|
+
// ─────────────────── ADD / CREATE ───────────────────
|
|
107
|
+
if (action === 'add' || action === 'create') {
|
|
108
|
+
const subject = params.subject || params.content;
|
|
109
|
+
if (!subject) return { success: false, error: 'subject (veya content) gerekli' };
|
|
110
|
+
const todo = _normalizeTodo({
|
|
111
|
+
id: _genId(),
|
|
112
|
+
subject,
|
|
113
|
+
description: params.description || '',
|
|
114
|
+
activeForm: params.activeForm || subject,
|
|
115
|
+
status: 'pending',
|
|
116
|
+
priority: params.priority || 'medium',
|
|
117
|
+
owner: params.owner || null,
|
|
118
|
+
blockedBy: Array.isArray(params.blockedBy) ? params.blockedBy : [],
|
|
119
|
+
blocks: Array.isArray(params.blocks) ? params.blocks : [],
|
|
120
|
+
metadata: params.metadata || {},
|
|
121
|
+
createdAt: new Date().toISOString(),
|
|
122
|
+
updatedAt: new Date().toISOString(),
|
|
123
|
+
});
|
|
41
124
|
todos.push(todo);
|
|
42
|
-
|
|
43
|
-
|
|
125
|
+
// Bidirectional link: if A blocks B, B is blockedBy A.
|
|
126
|
+
for (const blockedId of todo.blocks) {
|
|
127
|
+
const other = todos.find(t => t.id === blockedId);
|
|
128
|
+
if (other && !other.blockedBy.includes(todo.id)) {
|
|
129
|
+
other.blockedBy.push(todo.id);
|
|
130
|
+
other.updatedAt = new Date().toISOString();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
_saveTodos(todos);
|
|
134
|
+
return { success: true, todo, message: `Görev eklendi: ${subject}` };
|
|
44
135
|
}
|
|
45
136
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
137
|
+
// ─────────────────── UPDATE / START / DONE / REOPEN ───────────────────
|
|
138
|
+
if (action === 'update' || action === 'start' || action === 'done' || action === 'completed' || action === 'reopen') {
|
|
139
|
+
if (!params.id) return { success: false, error: 'id gerekli' };
|
|
140
|
+
const idx = todos.findIndex(t => t.id === params.id);
|
|
141
|
+
if (idx < 0) return { success: false, error: `Todo bulunamadı: ${params.id}` };
|
|
142
|
+
|
|
143
|
+
const todo = todos[idx];
|
|
144
|
+
|
|
145
|
+
if (action === 'start') {
|
|
146
|
+
// Refuse to start if blocked
|
|
147
|
+
if (_isBlocked(todo, todos)) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error: `Görev bloklu — şu açık görevler bitmeden başlayamaz: ${todo.blockedBy.join(', ')}`,
|
|
151
|
+
blockedBy: todo.blockedBy,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
todo.status = 'in_progress';
|
|
155
|
+
} else if (action === 'done' || action === 'completed') {
|
|
156
|
+
todo.status = 'completed';
|
|
157
|
+
todo.completedAt = new Date().toISOString();
|
|
158
|
+
} else if (action === 'reopen') {
|
|
159
|
+
todo.status = 'pending';
|
|
160
|
+
todo.completedAt = null;
|
|
161
|
+
} else if (action === 'update') {
|
|
162
|
+
if (params.subject !== undefined) todo.subject = params.subject;
|
|
163
|
+
if (params.description !== undefined) todo.description = params.description;
|
|
164
|
+
if (params.activeForm !== undefined) todo.activeForm = params.activeForm;
|
|
165
|
+
if (params.priority !== undefined && VALID_PRIORITIES.has(params.priority)) {
|
|
166
|
+
todo.priority = params.priority;
|
|
167
|
+
}
|
|
168
|
+
if (params.status !== undefined) {
|
|
169
|
+
const ns = _normalizeStatus(params.status);
|
|
170
|
+
if (ns) todo.status = ns;
|
|
171
|
+
if (ns === 'completed') todo.completedAt = new Date().toISOString();
|
|
172
|
+
}
|
|
173
|
+
if (params.owner !== undefined) todo.owner = params.owner;
|
|
174
|
+
if (Array.isArray(params.addBlockedBy)) {
|
|
175
|
+
for (const blockId of params.addBlockedBy) {
|
|
176
|
+
if (!todo.blockedBy.includes(blockId)) todo.blockedBy.push(blockId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (Array.isArray(params.addBlocks)) {
|
|
180
|
+
for (const blockedId of params.addBlocks) {
|
|
181
|
+
if (!todo.blocks.includes(blockedId)) todo.blocks.push(blockedId);
|
|
182
|
+
const other = todos.find(t => t.id === blockedId);
|
|
183
|
+
if (other && !other.blockedBy.includes(todo.id)) other.blockedBy.push(todo.id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (params.metadata && typeof params.metadata === 'object') {
|
|
187
|
+
for (const [k, v] of Object.entries(params.metadata)) {
|
|
188
|
+
if (v === null) delete todo.metadata[k];
|
|
189
|
+
else todo.metadata[k] = v;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
todo.updatedAt = new Date().toISOString();
|
|
194
|
+
_saveTodos(todos);
|
|
195
|
+
return { success: true, todo, message: `Güncellendi: ${todo.subject}` };
|
|
54
196
|
}
|
|
55
197
|
|
|
56
|
-
|
|
57
|
-
|
|
198
|
+
// ─────────────────── REMOVE / CLEAR ───────────────────
|
|
199
|
+
if (action === 'remove' || action === 'delete') {
|
|
200
|
+
if (!params.id) return { success: false, error: 'id gerekli' };
|
|
58
201
|
const before = todos.length;
|
|
59
|
-
todos = todos.filter(t => t.id !== id);
|
|
60
|
-
|
|
202
|
+
todos = todos.filter(t => t.id !== params.id);
|
|
203
|
+
// Also clean up any blockedBy references that now point at nothing
|
|
204
|
+
for (const t of todos) {
|
|
205
|
+
t.blockedBy = t.blockedBy.filter(bid => todos.some(x => x.id === bid));
|
|
206
|
+
t.blocks = t.blocks.filter(bid => todos.some(x => x.id === bid));
|
|
207
|
+
}
|
|
208
|
+
_saveTodos(todos);
|
|
61
209
|
return { success: true, removed: before - todos.length };
|
|
62
210
|
}
|
|
63
211
|
|
|
64
|
-
if (action ===
|
|
65
|
-
|
|
66
|
-
|
|
212
|
+
if (action === 'clear') {
|
|
213
|
+
const n = todos.length;
|
|
214
|
+
_saveTodos([]);
|
|
215
|
+
return { success: true, cleared: n, message: 'Tüm görevler temizlendi' };
|
|
67
216
|
}
|
|
68
217
|
|
|
69
218
|
return { success: false, error: `Bilinmeyen action: ${action}` };
|
|
70
219
|
}
|
|
71
220
|
|
|
72
221
|
module.exports = {
|
|
73
|
-
name:
|
|
74
|
-
description:
|
|
222
|
+
name: 'todo_write',
|
|
223
|
+
description:
|
|
224
|
+
'Görev / todo yöneticisi. action: list, get, add (alias: create), ' +
|
|
225
|
+
'update, start (pending→in_progress, blokluysa reddeder), ' +
|
|
226
|
+
'done (alias: completed), reopen, remove (alias: delete), clear. ' +
|
|
227
|
+
'Yeni alanlar: subject, description, activeForm (spinner için), ' +
|
|
228
|
+
'priority, owner, blockedBy, blocks, metadata. Legacy {content, ' +
|
|
229
|
+
'status: "done"} çağrıları hala geçerli.',
|
|
75
230
|
inputSchema: {
|
|
76
|
-
type:
|
|
231
|
+
type: 'object',
|
|
77
232
|
properties: {
|
|
78
|
-
action: {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
233
|
+
action: {
|
|
234
|
+
type: 'string',
|
|
235
|
+
description: 'list / get / add / create / update / start / done / completed / reopen / remove / delete / clear',
|
|
236
|
+
enum: ['list', 'get', 'add', 'create', 'update', 'start', 'done', 'completed', 'reopen', 'remove', 'delete', 'clear'],
|
|
237
|
+
},
|
|
238
|
+
id: { type: 'string', description: 'Görev ID (get/update/start/done/reopen/remove için)' },
|
|
239
|
+
content: { type: 'string', description: 'LEGACY: subject ile aynı, geriye uyumluluk için tutuluyor' },
|
|
240
|
+
subject: { type: 'string', description: 'Kısa başlık (zorunlu, add için)' },
|
|
241
|
+
description: { type: 'string', description: 'Uzun açıklama (opsiyonel)' },
|
|
242
|
+
activeForm: { type: 'string', description: 'Devam ederken gösterilen form (örn: "Testleri çalıştırıyor")' },
|
|
243
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'done', 'deleted'] },
|
|
244
|
+
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
245
|
+
owner: { type: 'string', description: 'Atanan agent/kullanıcı adı' },
|
|
246
|
+
blockedBy: { type: 'array', items: { type: 'string' }, description: 'Bu göreve önce tamamlanması gerekenler' },
|
|
247
|
+
blocks: { type: 'array', items: { type: 'string' }, description: 'Bu görev tamamlanmadan başlayamayacaklar (ters yönlü blockedBy)' },
|
|
248
|
+
addBlockedBy: { type: 'array', items: { type: 'string' }, description: 'update için: mevcut blockedBy listesine ekle' },
|
|
249
|
+
addBlocks: { type: 'array', items: { type: 'string' }, description: 'update için: mevcut blocks listesine ekle' },
|
|
250
|
+
metadata: { type: 'object', description: 'Arbitrary key/value notlar (update için: null ile sil)' },
|
|
83
251
|
},
|
|
84
252
|
},
|
|
85
|
-
async execute(params) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
};
|
|
253
|
+
async execute(params) { return todoAction(params); },
|
|
254
|
+
_internals: { todoAction, _normalizeTodo, _isBlocked, _genId, TODO_FILE },
|
|
255
|
+
};
|