kiosapi 0.1.27 → 0.1.29
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/dist/agent/mcp.js +205 -0
- package/dist/agent/run.js +110 -25
- package/dist/agent/skills.js +84 -0
- package/dist/agent/team.js +136 -56
- package/dist/commands.js +223 -3
- package/dist/config.js +73 -1
- package/dist/help.js +19 -0
- package/dist/index.js +4 -1
- package/dist/session.js +125 -3
- package/package.json +1 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) client — stdio transport only.
|
|
3
|
+
*
|
|
4
|
+
* Protocol: newline-delimited JSON-RPC 2.0 over child process stdin/stdout.
|
|
5
|
+
* No external SDK needed — the protocol is simple enough to implement directly.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { VERSION } from '../help.js';
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Single server connection
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
class McpConnection {
|
|
13
|
+
serverName;
|
|
14
|
+
proc;
|
|
15
|
+
buffer = '';
|
|
16
|
+
pending = new Map();
|
|
17
|
+
nextId = 1;
|
|
18
|
+
dead = false;
|
|
19
|
+
constructor(serverName, config) {
|
|
20
|
+
this.serverName = serverName;
|
|
21
|
+
const env = { ...process.env, ...(config.env ?? {}) };
|
|
22
|
+
// On Windows, npx/npm commands need shell:true to resolve correctly
|
|
23
|
+
const needsShell = process.platform === 'win32' &&
|
|
24
|
+
(config.command === 'npx' || config.command === 'npm' || config.command === 'node');
|
|
25
|
+
this.proc = spawn(config.command, config.args ?? [], {
|
|
26
|
+
env,
|
|
27
|
+
stdio: ['pipe', 'pipe', 'pipe'], // capture stderr so it doesn't pollute CLI output
|
|
28
|
+
shell: needsShell,
|
|
29
|
+
});
|
|
30
|
+
this.proc.stdout?.setEncoding('utf8');
|
|
31
|
+
this.proc.stdout?.on('data', (chunk) => {
|
|
32
|
+
this.buffer += chunk;
|
|
33
|
+
const lines = this.buffer.split('\n');
|
|
34
|
+
this.buffer = lines.pop() ?? '';
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
if (!line.trim())
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
const msg = JSON.parse(line);
|
|
40
|
+
if (msg.id !== undefined) {
|
|
41
|
+
const p = this.pending.get(msg.id);
|
|
42
|
+
if (p) {
|
|
43
|
+
clearTimeout(p.timer);
|
|
44
|
+
this.pending.delete(msg.id);
|
|
45
|
+
if (msg.error) {
|
|
46
|
+
p.reject(new Error(msg.error.message));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
p.resolve(msg.result);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// non-JSON line (debug output, etc.) — ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const onExit = () => {
|
|
60
|
+
this.dead = true;
|
|
61
|
+
for (const p of this.pending.values()) {
|
|
62
|
+
clearTimeout(p.timer);
|
|
63
|
+
p.reject(new Error(`MCP server "${this.serverName}" exited unexpectedly`));
|
|
64
|
+
}
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
};
|
|
67
|
+
this.proc.on('exit', onExit);
|
|
68
|
+
this.proc.on('error', onExit);
|
|
69
|
+
}
|
|
70
|
+
write(msg) {
|
|
71
|
+
this.proc.stdin?.write(`${JSON.stringify(msg)}\n`);
|
|
72
|
+
}
|
|
73
|
+
request(method, params, timeoutMs = 30_000) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
if (this.dead) {
|
|
76
|
+
reject(new Error(`MCP server "${this.serverName}" is not running`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const id = this.nextId++;
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
this.pending.delete(id);
|
|
82
|
+
reject(new Error(`MCP "${this.serverName}" timeout after ${timeoutMs}ms`));
|
|
83
|
+
}, timeoutMs);
|
|
84
|
+
this.pending.set(id, { resolve: (r) => resolve(r), reject, timer });
|
|
85
|
+
this.write({ jsonrpc: '2.0', id, method, params });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async initialize() {
|
|
89
|
+
await this.request('initialize', {
|
|
90
|
+
protocolVersion: '2024-11-05',
|
|
91
|
+
clientInfo: { name: 'kiosapi', version: VERSION },
|
|
92
|
+
capabilities: { tools: {} },
|
|
93
|
+
}, 10_000);
|
|
94
|
+
// Notification — no response expected
|
|
95
|
+
this.write({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
96
|
+
}
|
|
97
|
+
async listTools() {
|
|
98
|
+
const result = await this.request('tools/list', {}, 10_000);
|
|
99
|
+
return result.tools ?? [];
|
|
100
|
+
}
|
|
101
|
+
async callTool(name, args) {
|
|
102
|
+
const result = await this.request('tools/call', { name, arguments: args }, 60_000);
|
|
103
|
+
const content = result.content ?? [];
|
|
104
|
+
const text = content
|
|
105
|
+
.filter((c) => c.type === 'text' && c.text)
|
|
106
|
+
.map((c) => c.text ?? '')
|
|
107
|
+
.join('\n');
|
|
108
|
+
if (result.isError)
|
|
109
|
+
throw new Error(`MCP tool error: ${text}`);
|
|
110
|
+
return text || '(no output)';
|
|
111
|
+
}
|
|
112
|
+
kill() {
|
|
113
|
+
this.dead = true;
|
|
114
|
+
try {
|
|
115
|
+
this.proc.kill();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// already dead
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
get isAlive() {
|
|
122
|
+
return !this.dead;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Manager: multiple servers, unified tool registry
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
function sanitizeName(s) {
|
|
129
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
130
|
+
}
|
|
131
|
+
export class McpManager {
|
|
132
|
+
connections = new Map();
|
|
133
|
+
registry = [];
|
|
134
|
+
/**
|
|
135
|
+
* Connect to all configured servers in parallel. Failures are logged but do not abort — a
|
|
136
|
+
* partially-connected MCP set is better than no MCP at all.
|
|
137
|
+
*/
|
|
138
|
+
async connect(servers, onWarn) {
|
|
139
|
+
const entries = Object.entries(servers);
|
|
140
|
+
const results = await Promise.allSettled(entries.map(async ([name, config]) => {
|
|
141
|
+
const conn = new McpConnection(name, config);
|
|
142
|
+
await conn.initialize();
|
|
143
|
+
const tools = await conn.listTools();
|
|
144
|
+
this.connections.set(name, conn);
|
|
145
|
+
for (const tool of tools) {
|
|
146
|
+
const qualifiedName = `mcp__${sanitizeName(name)}__${sanitizeName(tool.name)}`;
|
|
147
|
+
this.registry.push({
|
|
148
|
+
qualifiedName,
|
|
149
|
+
serverName: name,
|
|
150
|
+
toolName: tool.name,
|
|
151
|
+
spec: {
|
|
152
|
+
type: 'function',
|
|
153
|
+
function: {
|
|
154
|
+
name: qualifiedName,
|
|
155
|
+
description: `[MCP:${name}] ${tool.description ?? tool.name}`,
|
|
156
|
+
parameters: tool.inputSchema,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}));
|
|
162
|
+
for (const [i, result] of results.entries()) {
|
|
163
|
+
if (result.status === 'rejected') {
|
|
164
|
+
const serverName = entries[i]?.[0] ?? '?';
|
|
165
|
+
onWarn?.(`⚠ MCP server "${serverName}" gagal: ${result.reason.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** ToolSpec[] for all connected MCP tools — merged into the agent's tool list. */
|
|
170
|
+
getToolSpecs() {
|
|
171
|
+
return this.registry.map((r) => r.spec);
|
|
172
|
+
}
|
|
173
|
+
/** Dispatch a tool call by qualified name. */
|
|
174
|
+
async callTool(qualifiedName, args) {
|
|
175
|
+
const entry = this.registry.find((r) => r.qualifiedName === qualifiedName);
|
|
176
|
+
if (!entry)
|
|
177
|
+
throw new Error(`MCP tool "${qualifiedName}" tidak dikenal`);
|
|
178
|
+
const conn = this.connections.get(entry.serverName);
|
|
179
|
+
if (!conn?.isAlive)
|
|
180
|
+
throw new Error(`MCP server "${entry.serverName}" tidak aktif`);
|
|
181
|
+
return conn.callTool(entry.toolName, args);
|
|
182
|
+
}
|
|
183
|
+
get toolCount() {
|
|
184
|
+
return this.registry.length;
|
|
185
|
+
}
|
|
186
|
+
get serverCount() {
|
|
187
|
+
return this.connections.size;
|
|
188
|
+
}
|
|
189
|
+
/** Summary of servers and their tool counts — for display. */
|
|
190
|
+
summary() {
|
|
191
|
+
const byServer = new Map();
|
|
192
|
+
for (const r of this.registry) {
|
|
193
|
+
if (!byServer.has(r.serverName))
|
|
194
|
+
byServer.set(r.serverName, []);
|
|
195
|
+
byServer.get(r.serverName).push(r);
|
|
196
|
+
}
|
|
197
|
+
return [...byServer.entries()].map(([server, tools]) => ({ server, tools }));
|
|
198
|
+
}
|
|
199
|
+
disconnect() {
|
|
200
|
+
for (const conn of this.connections.values())
|
|
201
|
+
conn.kill();
|
|
202
|
+
this.connections.clear();
|
|
203
|
+
this.registry = [];
|
|
204
|
+
}
|
|
205
|
+
}
|
package/dist/agent/run.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { extname, join } from 'node:path';
|
|
3
3
|
import { chatComplete, generateImage, resolveMediaModel, streamVision, } from '../api.js';
|
|
4
|
-
import { CHECKPOINT_PATH } from '../config.js';
|
|
4
|
+
import { CHECKPOINT_PATH, loadMcpServers } from '../config.js';
|
|
5
5
|
import { cyan, dim, green, idn, prompt, red, rupiah, thinking, yellow } from '../ui.js';
|
|
6
|
+
import { McpManager } from './mcp.js';
|
|
6
7
|
import { systemPrompt, toolsForMode } from './schemas.js';
|
|
7
8
|
import { bacaFile, cari, daftarFile, editFile, hapusFile, jalankan, pindahFile, safePath, tulisFile, } from './tools.js';
|
|
8
9
|
const MAX_STEPS = 50;
|
|
@@ -56,32 +57,47 @@ function trimContext(messages) {
|
|
|
56
57
|
const dropped = clean.length - tail.length;
|
|
57
58
|
if (dropped <= 0)
|
|
58
59
|
return [system, ...clean];
|
|
59
|
-
// Collect baca_file
|
|
60
|
-
// already has context for — prevents
|
|
60
|
+
// Collect baca_file and daftar_file calls from the dropped messages so the model knows which
|
|
61
|
+
// files/directories it already has context for — prevents re-orientation loops after trimming.
|
|
61
62
|
const droppedHead = clean.slice(0, clean.length - tail.length + skip);
|
|
62
63
|
const droppedPaths = [];
|
|
64
|
+
const droppedDirs = [];
|
|
65
|
+
const droppedModified = [];
|
|
63
66
|
for (const msg of droppedHead) {
|
|
64
67
|
if (msg.role === 'assistant' && msg.tool_calls) {
|
|
65
68
|
for (const tc of msg.tool_calls) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
try {
|
|
70
|
+
const a = JSON.parse(tc.function.arguments ?? '{}');
|
|
71
|
+
if (tc.function?.name === 'baca_file' && a.path)
|
|
72
|
+
droppedPaths.push(a.path);
|
|
73
|
+
if (tc.function?.name === 'daftar_file')
|
|
74
|
+
droppedDirs.push(a.path || '.');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* ignore */
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
// Track files that were successfully written/edited/deleted in the dropped context so the
|
|
82
|
+
// model knows it already made those changes and shouldn't repeat them after trimming.
|
|
83
|
+
if (msg.role === 'tool' && msg.content) {
|
|
84
|
+
const m = msg.content.match(/^OK:\s+(.+?)\s+(dibuat|diubah|dihapus|dipindah)/);
|
|
85
|
+
if (m?.[1])
|
|
86
|
+
droppedModified.push(m[1]);
|
|
87
|
+
}
|
|
78
88
|
}
|
|
79
89
|
const filesNote = droppedPaths.length > 0
|
|
80
|
-
? ` File sudah dibaca
|
|
90
|
+
? ` File sudah dibaca: ${[...new Set(droppedPaths)].join(', ')}. JANGAN baca ulang — gunakan cari untuk teks spesifik.`
|
|
91
|
+
: '';
|
|
92
|
+
const dirsNote = droppedDirs.length > 0
|
|
93
|
+
? ` Direktori sudah terdaftar: ${[...new Set(droppedDirs)].join(', ')}. JANGAN list ulang — gunakan baca_file untuk file spesifik.`
|
|
94
|
+
: '';
|
|
95
|
+
const modifiedNote = droppedModified.length > 0
|
|
96
|
+
? ` File sudah dimodifikasi: ${[...new Set(droppedModified)].join(', ')}. JANGAN tulis ulang kecuali ada perubahan tambahan.`
|
|
81
97
|
: '';
|
|
82
98
|
const note = {
|
|
83
99
|
role: 'system',
|
|
84
|
-
content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks.${filesNote}]`,
|
|
100
|
+
content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks.${filesNote}${dirsNote}${modifiedNote}]`,
|
|
85
101
|
};
|
|
86
102
|
return [system, note, ...tail];
|
|
87
103
|
}
|
|
@@ -472,7 +488,8 @@ export async function runTurn(s, userText) {
|
|
|
472
488
|
}
|
|
473
489
|
}
|
|
474
490
|
s.messages.push({ role: 'user', content: userText });
|
|
475
|
-
|
|
491
|
+
s._lastCompleted = false;
|
|
492
|
+
const tools = [...toolsForMode(s.mode), ...(s.mcpManager?.getToolSpecs() ?? [])];
|
|
476
493
|
let lastText = '';
|
|
477
494
|
let totalIn = 0;
|
|
478
495
|
let totalOut = 0;
|
|
@@ -482,12 +499,15 @@ export async function runTurn(s, userText) {
|
|
|
482
499
|
}
|
|
483
500
|
};
|
|
484
501
|
// Loop detection: count total calls per signature (tool+args) across the whole runTurn.
|
|
485
|
-
// Consecutive-3 catches obvious tight loops; count-
|
|
486
|
-
//
|
|
502
|
+
// Consecutive-3 catches obvious tight loops; count-N catches spread-out repetition.
|
|
503
|
+
// daftar_file has a lower limit (3) because re-listing the same directory is almost never
|
|
504
|
+
// useful; baca_file legitimately needs more chances (range reads + post-edit re-reads).
|
|
487
505
|
const callCounts = new Map();
|
|
488
506
|
const lastSigs = []; // last 3 sigs for consecutive detection
|
|
489
|
-
const
|
|
507
|
+
const COUNT_LIMITS = { daftar_file: 3, cari: 4 };
|
|
508
|
+
const DEFAULT_COUNT_LIMIT = 6;
|
|
490
509
|
const CONSEC_LIMIT = 3; // same sig 3× in a row → loop
|
|
510
|
+
const countLimitFor = (toolName) => COUNT_LIMITS[toolName] ?? DEFAULT_COUNT_LIMIT;
|
|
491
511
|
// Read-only tool cache: on 2nd+ identical call, return the previous result with a warning
|
|
492
512
|
// instead of re-running. This gives the model early feedback so it can self-correct before
|
|
493
513
|
// COUNT_LIMIT is reached, preventing the common "list root, list root, list root" pattern.
|
|
@@ -553,9 +573,9 @@ export async function runTurn(s, userText) {
|
|
|
553
573
|
lastSigs.push(sig);
|
|
554
574
|
if (lastSigs.length > CONSEC_LIMIT)
|
|
555
575
|
lastSigs.shift();
|
|
556
|
-
// Total count exceeded, or 3 in a row
|
|
576
|
+
// Total count exceeded (per-tool limit), or 3 in a row
|
|
557
577
|
const consecutive = lastSigs.length === CONSEC_LIMIT && lastSigs.every((s) => s === sig);
|
|
558
|
-
if (count >=
|
|
578
|
+
if (count >= countLimitFor(call.function.name) || consecutive) {
|
|
559
579
|
loopSig = sig;
|
|
560
580
|
break;
|
|
561
581
|
}
|
|
@@ -565,7 +585,14 @@ export async function runTurn(s, userText) {
|
|
|
565
585
|
const total = callCounts.get(loopSig) ?? 0;
|
|
566
586
|
console.log(red(`\n⚠ Loop terdeteksi: "${loopName}" dipanggil ${total}× — agen terjebak.`));
|
|
567
587
|
console.log(yellow('Berikan instruksi baru, atau ketik /bersih untuk mulai ulang.'));
|
|
568
|
-
const loopMsg = `⚠ LOOP: "${loopName}" sudah dipanggil ${total}×
|
|
588
|
+
const loopMsg = `⚠ LOOP TERDETEKSI: "${loopName}" sudah dipanggil ${total}× — BERHENTI SEKARANG.
|
|
589
|
+
|
|
590
|
+
Panggil tool "selesai" dengan ringkasan yang berisi:
|
|
591
|
+
1. Apa yang SUDAH berhasil dikerjakan (file diubah, perintah dijalankan)
|
|
592
|
+
2. Apa yang BELUM selesai dan kendala yang ditemui
|
|
593
|
+
3. Saran langkah berikutnya untuk pengguna
|
|
594
|
+
|
|
595
|
+
JANGAN panggil tool lain selain "selesai".`;
|
|
569
596
|
for (const call of calls) {
|
|
570
597
|
s.messages.push({ role: 'tool', content: loopMsg, tool_call_id: call.id });
|
|
571
598
|
}
|
|
@@ -588,9 +615,13 @@ export async function runTurn(s, userText) {
|
|
|
588
615
|
// trimContext drops old messages from its window. The escalating warning text
|
|
589
616
|
// discourages repetition; withholding the content at count=3+ only causes the model
|
|
590
617
|
// to keep retrying — counter-productive to stopping the loop.
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
618
|
+
const toolLimit = countLimitFor(toolName);
|
|
619
|
+
const isLastChance = count >= toolLimit - 1;
|
|
620
|
+
const stopNote = isLastChance
|
|
621
|
+
? `\n\n⚠ STOP (ke-${count}/${toolLimit}): JANGAN panggil "${toolName}" lagi dengan argumen yang sama. Gunakan tool "selesai" dan jelaskan kendalanya, atau gunakan cari/baca_file dengan path/range BERBEDA.`
|
|
622
|
+
: toolName === 'daftar_file'
|
|
623
|
+
? `\n\n⚠ Cache ke-${count}: direktori ini SUDAH terdaftar. JANGAN list ulang — gunakan baca_file(path) untuk file spesifik di dalamnya.`
|
|
624
|
+
: `\n\n⚠ Cache ke-${count}: "${toolName}" sudah dipanggil ${count}× dengan argumen sama. Gunakan cari atau baca_file(path, mulai=N) untuk bagian berbeda.`;
|
|
594
625
|
const warn = `[Cache ke-${count}]\n${cachedOut}${stopNote}`;
|
|
595
626
|
const cachePathHint = (() => {
|
|
596
627
|
try {
|
|
@@ -604,6 +635,45 @@ export async function runTurn(s, userText) {
|
|
|
604
635
|
s.messages.push({ role: 'tool', content: warn, tool_call_id: call.id });
|
|
605
636
|
continue;
|
|
606
637
|
}
|
|
638
|
+
// MCP tool dispatch — routed to the connected MCP server
|
|
639
|
+
if (call.function.name.startsWith('mcp__') && s.mcpManager) {
|
|
640
|
+
let mcpArgs = {};
|
|
641
|
+
try {
|
|
642
|
+
mcpArgs = JSON.parse(call.function.arguments || '{}');
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
s.messages.push({
|
|
646
|
+
role: 'tool',
|
|
647
|
+
content: 'Error: argumen bukan JSON valid.',
|
|
648
|
+
tool_call_id: call.id,
|
|
649
|
+
});
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const [, serverPart, ...toolParts] = call.function.name.split('__');
|
|
653
|
+
console.log(dim(` mcp:${serverPart} → ${toolParts.join('__')}`));
|
|
654
|
+
if (!(await allow(s.otomatis, `Jalankan MCP tool "${call.function.name}"?`))) {
|
|
655
|
+
s.messages.push({
|
|
656
|
+
role: 'tool',
|
|
657
|
+
content: 'Ditolak oleh pengguna.',
|
|
658
|
+
tool_call_id: call.id,
|
|
659
|
+
});
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
let mcpOut;
|
|
663
|
+
try {
|
|
664
|
+
mcpOut = await s.mcpManager.callTool(call.function.name, mcpArgs);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
mcpOut = `Error MCP: ${err instanceof Error ? err.message : String(err)}`;
|
|
668
|
+
}
|
|
669
|
+
const MAX_STORED_RESULT_MCP = 10_000;
|
|
670
|
+
const stored = mcpOut.length > MAX_STORED_RESULT_MCP
|
|
671
|
+
? `${mcpOut.slice(0, MAX_STORED_RESULT_MCP)}\n…[dipotong]`
|
|
672
|
+
: mcpOut;
|
|
673
|
+
s.messages.push({ role: 'tool', content: stored, tool_call_id: call.id });
|
|
674
|
+
saveCheckpoint(s);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
607
677
|
const result = await runTool(call, s.otomatis, s.model);
|
|
608
678
|
// Cap what goes into the conversation history: full output can be 300 lines of directory
|
|
609
679
|
// listing or 100 KB of file content, which quickly overflows small-context-window models
|
|
@@ -633,6 +703,7 @@ export async function runTurn(s, userText) {
|
|
|
633
703
|
console.log(green(`\n✓ Selesai: ${result.output}`));
|
|
634
704
|
showUsage();
|
|
635
705
|
s.totalTokens += totalIn + totalOut;
|
|
706
|
+
s._lastCompleted = true;
|
|
636
707
|
clearCheckpoint();
|
|
637
708
|
return result.output;
|
|
638
709
|
}
|
|
@@ -651,5 +722,19 @@ export async function runTurn(s, userText) {
|
|
|
651
722
|
}
|
|
652
723
|
/** One-shot: run a single task in a mode (used by the `rencana`/`edit`/`buat` subcommands). */
|
|
653
724
|
export async function runAgent(task, mode, opts) {
|
|
654
|
-
|
|
725
|
+
const s = newSession(opts.model, mode, opts.otomatis);
|
|
726
|
+
const mcpServers = loadMcpServers();
|
|
727
|
+
let mcpManager;
|
|
728
|
+
if (Object.keys(mcpServers).length > 0) {
|
|
729
|
+
mcpManager = new McpManager();
|
|
730
|
+
await mcpManager.connect(mcpServers);
|
|
731
|
+
if (mcpManager.toolCount > 0)
|
|
732
|
+
s.mcpManager = mcpManager;
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
await runTurn(s, task);
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
mcpManager?.disconnect();
|
|
739
|
+
}
|
|
655
740
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { SKILLS_GLOBAL_DIR, projectSkillsDir } from '../config.js';
|
|
4
|
+
function parseFrontmatter(content) {
|
|
5
|
+
if (!content.startsWith('---\n'))
|
|
6
|
+
return { meta: {}, body: content.trim() };
|
|
7
|
+
const end = content.indexOf('\n---\n', 4);
|
|
8
|
+
if (end === -1)
|
|
9
|
+
return { meta: {}, body: content.trim() };
|
|
10
|
+
const raw = content.slice(4, end);
|
|
11
|
+
const meta = {};
|
|
12
|
+
for (const line of raw.split('\n')) {
|
|
13
|
+
const trimmed = line.trim();
|
|
14
|
+
if (trimmed.startsWith('#'))
|
|
15
|
+
continue;
|
|
16
|
+
const match = trimmed.match(/^(\w+):\s*(.*)$/);
|
|
17
|
+
if (match?.[1])
|
|
18
|
+
meta[match[1]] = match[2]?.trim() ?? '';
|
|
19
|
+
}
|
|
20
|
+
return { meta, body: content.slice(end + 5).trim() };
|
|
21
|
+
}
|
|
22
|
+
function readSkillFile(filePath, source) {
|
|
23
|
+
try {
|
|
24
|
+
const content = readFileSync(filePath, 'utf8');
|
|
25
|
+
const { meta, body } = parseFrontmatter(content);
|
|
26
|
+
const name = filePath.split(/[/\\]/).pop()?.replace(/\.md$/, '') ?? '';
|
|
27
|
+
if (!name || !body)
|
|
28
|
+
return null;
|
|
29
|
+
const modeRaw = meta.mode ?? 'buat';
|
|
30
|
+
const mode = modeRaw === 'rencana' || modeRaw === 'edit' ? modeRaw : 'buat';
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
description: meta.description ?? '',
|
|
34
|
+
prompt: body,
|
|
35
|
+
mode,
|
|
36
|
+
otomatis: meta.otomatis === 'true',
|
|
37
|
+
model: meta.model || undefined,
|
|
38
|
+
source,
|
|
39
|
+
filePath,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Load a skill by name: project (.kiosapi/skills/) overrides global (~/.kiosapi/skills/). */
|
|
47
|
+
export function loadSkill(name) {
|
|
48
|
+
const projectPath = join(projectSkillsDir(), `${name}.md`);
|
|
49
|
+
if (existsSync(projectPath))
|
|
50
|
+
return readSkillFile(projectPath, 'project');
|
|
51
|
+
const globalPath = join(SKILLS_GLOBAL_DIR, `${name}.md`);
|
|
52
|
+
if (existsSync(globalPath))
|
|
53
|
+
return readSkillFile(globalPath, 'global');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** List all available skills (project overrides global on name collision). */
|
|
57
|
+
export function listSkills() {
|
|
58
|
+
const skills = new Map();
|
|
59
|
+
// Load global first so project can override
|
|
60
|
+
for (const { dir, source } of [
|
|
61
|
+
{ dir: SKILLS_GLOBAL_DIR, source: 'global' },
|
|
62
|
+
{ dir: projectSkillsDir(), source: 'project' },
|
|
63
|
+
]) {
|
|
64
|
+
if (!existsSync(dir))
|
|
65
|
+
continue;
|
|
66
|
+
try {
|
|
67
|
+
for (const file of readdirSync(dir)) {
|
|
68
|
+
if (!file.endsWith('.md'))
|
|
69
|
+
continue;
|
|
70
|
+
const sk = readSkillFile(join(dir, file), source);
|
|
71
|
+
if (sk)
|
|
72
|
+
skills.set(sk.name, sk);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* unreadable dir — ignore */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...skills.values()].sort((a, b) => {
|
|
80
|
+
if (a.source !== b.source)
|
|
81
|
+
return a.source === 'project' ? -1 : 1;
|
|
82
|
+
return a.name.localeCompare(b.name);
|
|
83
|
+
});
|
|
84
|
+
}
|