kiosapi 0.1.28 → 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.
@@ -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;
@@ -61,6 +62,7 @@ function trimContext(messages) {
61
62
  const droppedHead = clean.slice(0, clean.length - tail.length + skip);
62
63
  const droppedPaths = [];
63
64
  const droppedDirs = [];
65
+ const droppedModified = [];
64
66
  for (const msg of droppedHead) {
65
67
  if (msg.role === 'assistant' && msg.tool_calls) {
66
68
  for (const tc of msg.tool_calls) {
@@ -76,6 +78,13 @@ function trimContext(messages) {
76
78
  }
77
79
  }
78
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
+ }
79
88
  }
80
89
  const filesNote = droppedPaths.length > 0
81
90
  ? ` File sudah dibaca: ${[...new Set(droppedPaths)].join(', ')}. JANGAN baca ulang — gunakan cari untuk teks spesifik.`
@@ -83,9 +92,12 @@ function trimContext(messages) {
83
92
  const dirsNote = droppedDirs.length > 0
84
93
  ? ` Direktori sudah terdaftar: ${[...new Set(droppedDirs)].join(', ')}. JANGAN list ulang — gunakan baca_file untuk file spesifik.`
85
94
  : '';
95
+ const modifiedNote = droppedModified.length > 0
96
+ ? ` File sudah dimodifikasi: ${[...new Set(droppedModified)].join(', ')}. JANGAN tulis ulang kecuali ada perubahan tambahan.`
97
+ : '';
86
98
  const note = {
87
99
  role: 'system',
88
- content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks.${filesNote}${dirsNote}]`,
100
+ content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks.${filesNote}${dirsNote}${modifiedNote}]`,
89
101
  };
90
102
  return [system, note, ...tail];
91
103
  }
@@ -476,7 +488,8 @@ export async function runTurn(s, userText) {
476
488
  }
477
489
  }
478
490
  s.messages.push({ role: 'user', content: userText });
479
- const tools = toolsForMode(s.mode);
491
+ s._lastCompleted = false;
492
+ const tools = [...toolsForMode(s.mode), ...(s.mcpManager?.getToolSpecs() ?? [])];
480
493
  let lastText = '';
481
494
  let totalIn = 0;
482
495
  let totalOut = 0;
@@ -572,7 +585,14 @@ export async function runTurn(s, userText) {
572
585
  const total = callCounts.get(loopSig) ?? 0;
573
586
  console.log(red(`\n⚠ Loop terdeteksi: "${loopName}" dipanggil ${total}× — agen terjebak.`));
574
587
  console.log(yellow('Berikan instruksi baru, atau ketik /bersih untuk mulai ulang.'));
575
- const loopMsg = `⚠ LOOP: "${loopName}" sudah dipanggil ${total}× dengan argumen yang sama. JANGAN panggil lagi. Gunakan tool "selesai" dan jelaskan kendalanya, atau minta klarifikasi dari pengguna.`;
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".`;
576
596
  for (const call of calls) {
577
597
  s.messages.push({ role: 'tool', content: loopMsg, tool_call_id: call.id });
578
598
  }
@@ -615,6 +635,45 @@ export async function runTurn(s, userText) {
615
635
  s.messages.push({ role: 'tool', content: warn, tool_call_id: call.id });
616
636
  continue;
617
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
+ }
618
677
  const result = await runTool(call, s.otomatis, s.model);
619
678
  // Cap what goes into the conversation history: full output can be 300 lines of directory
620
679
  // listing or 100 KB of file content, which quickly overflows small-context-window models
@@ -644,6 +703,7 @@ export async function runTurn(s, userText) {
644
703
  console.log(green(`\n✓ Selesai: ${result.output}`));
645
704
  showUsage();
646
705
  s.totalTokens += totalIn + totalOut;
706
+ s._lastCompleted = true;
647
707
  clearCheckpoint();
648
708
  return result.output;
649
709
  }
@@ -662,5 +722,19 @@ export async function runTurn(s, userText) {
662
722
  }
663
723
  /** One-shot: run a single task in a mode (used by the `rencana`/`edit`/`buat` subcommands). */
664
724
  export async function runAgent(task, mode, opts) {
665
- await runTurn(newSession(opts.model, mode, opts.otomatis), task);
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
+ }
666
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
+ }
@@ -1,4 +1,5 @@
1
1
  import { modelSupportsTools } from '../api.js';
2
+ import { clearTimCheckpoint, saveTimCheckpoint } from '../config.js';
2
3
  import { bold, dim, green, yellow } from '../ui.js';
3
4
  import { newSession, runTurn } from './run.js';
4
5
  /**
@@ -33,85 +34,164 @@ function tryParsePlan(output) {
33
34
  // ---------------------------------------------------------------------------
34
35
  // Built-in team: planner-executor
35
36
  // ---------------------------------------------------------------------------
36
- const PLANNER_BRIEF = `Peranmu: PERENCANA. Telusuri kode seperlunya lalu buat rencana langkah kecil & terstruktur.
37
+ const PLANNER_BRIEF = `Peranmu: PERENCANA. Baca SELURUH spesifikasi terlebih dahulu.
37
38
 
38
- WAJIB: panggil tool "selesai" dengan parameter ringkasan berisi JSON dalam format TEPAT ini (tidak ada teks lain sebelum/sesudah JSON):
39
+ LANGKAH WAJIB SEBELUM MERENCANAKAN:
40
+ 1. Cari dan baca file spesifikasi/blueprint: blueprint.md, SPEC.md, README.md, atau file .md di root
41
+ 2. Pelajari struktur proyek yang sudah ada (daftar_file)
42
+ 3. Baru buat rencana langkah yang KOMPREHENSIF mencakup SEMUA fitur yang diminta
43
+
44
+ WAJIB: panggil tool "selesai" dengan parameter ringkasan berisi JSON dalam format TEPAT ini:
39
45
  {"ringkasan":"<ringkasan singkat>","langkah":[{"tugas":"<deskripsi spesifik & actionable>","file":["path/file1.ts"],"catatan":"<konteks tambahan, opsional>"},...]}
40
46
 
41
47
  Aturan rencana:
42
- - Masing-masing langkah: 1–3 file, satu perubahan jelas, bisa selesai dalam ~15 tool calls.
43
- - Maksimal 10 langkah pecah yang kompleks, gabung yang trivial.
44
- - Sebutkan path file LENGKAP dan AKURAT (gunakan daftar_file untuk memastikan).
45
- - JANGAN menulis kode — hanya rencanakan.`;
48
+ - Masing-masing langkah: 1–3 file, satu perubahan jelas
49
+ - Maksimal 15 langkah (untuk aplikasi besar buat lebih banyak langkah kecil)
50
+ - Sebutkan path file LENGKAP dan AKURAT (gunakan daftar_file untuk memastikan)
51
+ - JANGAN menulis kode — hanya rencanakan
52
+ - Pastikan rencana LENGKAP — tidak ada fitur yang terlewat dari spesifikasi`;
46
53
  const REVIEWER_BRIEF = 'Peranmu: PENINJAU. Baca file yang relevan dan tinjau hasil implementasi: sebutkan masalah/risiko & saran perbaikan singkat.';
47
- export async function runTeam(task, opts) {
54
+ /** Append role brief to the existing system message (index 0) instead of pushing a second
55
+ * system message. Many providers reject or ignore a second {role:'system'} entry before the
56
+ * first user turn, causing the agent to ignore its role instructions entirely. */
57
+ function appendBrief(session, brief) {
58
+ const sys = session.messages[0];
59
+ if (sys?.role === 'system') {
60
+ sys.content += `\n\n${brief}`;
61
+ }
62
+ }
63
+ export async function runTeam(task, opts, resume) {
48
64
  console.log(bold('👥 Tim agen — mode perencana-eksekutor'));
49
- // Planner
50
- console.log(`\n${bold('① Perencana')} ${dim(`(${opts.models.perencana})`)}`);
51
- const plannerSession = newSession(opts.models.perencana, 'rencana', opts.otomatis);
52
- plannerSession.messages.push({ role: 'system', content: PLANNER_BRIEF });
53
- const planOutput = await runTurn(plannerSession, task);
54
- const plan = tryParsePlan(planOutput);
55
- if (!plan) {
56
- console.log(yellow('⚠ Perencana tidak menghasilkan rencana terstruktur jalankan sebagai sesi pengkode tunggal.'));
57
- const s = newSession(opts.models.pengkode, 'buat', opts.otomatis);
58
- s.messages.push({
59
- role: 'system',
60
- content: 'Peranmu: PENGKODE. Implementasikan tugas mengikuti rencana. Buat perubahan kecil & jelas, lalu panggil selesai.',
61
- });
62
- await runTurn(s, `Tugas: ${task}\n\nRencana:\n${planOutput || '(tidak ada rencana eksplisit)'}`);
63
- console.log(`\n${bold('③ Peninjau')} ${dim(`(${opts.models.peninjau})`)}`);
64
- const rev = newSession(opts.models.peninjau, 'rencana', opts.otomatis);
65
- rev.messages.push({ role: 'system', content: REVIEWER_BRIEF });
66
- await runTurn(rev, `Tinjau hasil implementasi untuk tugas: ${task}`);
67
- console.log(green('\n✓ Tim selesai.'));
68
- return;
65
+ let plan = null;
66
+ // If resuming with a saved plan, skip the planner phase entirely
67
+ if (resume?.plan) {
68
+ plan = resume.plan;
69
+ console.log(yellow(`↩ Melanjutkan dari rencana tersimpan (${plan.langkah.length} langkah)`));
70
+ console.log(bold(`\n📋 ${plan.ringkasan}`));
71
+ for (const [i, step] of plan.langkah.entries()) {
72
+ const done = i < (resume.startFromStep ?? 0);
73
+ const marker = done ? dim('') : dim(`${i + 1}.`);
74
+ const label = done ? dim(step.tugas) : step.tugas;
75
+ console.log(` ${marker} ${label}`);
76
+ if (!done) {
77
+ console.log(dim(` 📄 ${step.file.join(', ')}`));
78
+ if (step.catatan)
79
+ console.log(dim(` 💬 ${step.catatan}`));
80
+ }
81
+ }
82
+ console.log('');
69
83
  }
70
- // Show plan
71
- console.log(bold(`\n📋 ${plan.ringkasan}`));
72
- for (const [i, step] of plan.langkah.entries()) {
73
- console.log(` ${dim(`${i + 1}.`)} ${step.tugas}`);
74
- console.log(dim(` 📄 ${step.file.join(', ')}`));
75
- if (step.catatan)
76
- console.log(dim(` 💬 ${step.catatan}`));
84
+ else {
85
+ // ① Planner
86
+ console.log(`\n${bold('① Perencana')} ${dim(`(${opts.models.perencana})`)}`);
87
+ const plannerSession = newSession(opts.models.perencana, 'rencana', opts.otomatis);
88
+ appendBrief(plannerSession, PLANNER_BRIEF);
89
+ const planOutput = await runTurn(plannerSession, task);
90
+ plan = tryParsePlan(planOutput);
91
+ // Retry once if the planner didn't output valid JSON — it may have just forgotten the format.
92
+ if (!plan && planOutput.trim()) {
93
+ console.log(yellow('⚠ Rencana tidak valid — mencoba ulang sekali...'));
94
+ const retryOutput = await runTurn(plannerSession, 'Output sebelumnya tidak valid. Keluarkan HANYA JSON ini tanpa teks lain:\n{"ringkasan":"...","langkah":[{"tugas":"...","file":["path/file.ts"]}]}');
95
+ plan = tryParsePlan(retryOutput);
96
+ }
97
+ if (!plan) {
98
+ console.log(yellow('⚠ Perencana tidak menghasilkan rencana terstruktur — jalankan sebagai sesi pengkode tunggal.'));
99
+ const s = newSession(opts.models.pengkode, 'buat', opts.otomatis);
100
+ appendBrief(s, 'Peranmu: PENGKODE. Implementasikan tugas mengikuti rencana. Buat perubahan kecil & jelas, lalu panggil selesai.');
101
+ await runTurn(s, `Tugas: ${task}\n\nRencana:\n${planOutput || '(tidak ada rencana eksplisit)'}`);
102
+ clearTimCheckpoint();
103
+ console.log(`\n${bold('③ Peninjau')} ${dim(`(${opts.models.peninjau})`)}`);
104
+ const rev = newSession(opts.models.peninjau, 'rencana', opts.otomatis);
105
+ appendBrief(rev, REVIEWER_BRIEF);
106
+ await runTurn(rev, `Tinjau hasil implementasi untuk tugas: ${task}`);
107
+ console.log(green('\n✓ Tim selesai.'));
108
+ return;
109
+ }
110
+ // Show plan
111
+ console.log(bold(`\n📋 ${plan.ringkasan}`));
112
+ for (const [i, step] of plan.langkah.entries()) {
113
+ console.log(` ${dim(`${i + 1}.`)} ${step.tugas}`);
114
+ console.log(dim(` 📄 ${step.file.join(', ')}`));
115
+ if (step.catatan)
116
+ console.log(dim(` 💬 ${step.catatan}`));
117
+ }
118
+ console.log('');
119
+ // Save checkpoint as soon as the plan is established — if execution is interrupted,
120
+ // `kiosapi lanjut` can resume from step 0 with the existing plan.
121
+ saveTimCheckpoint({
122
+ task,
123
+ plan,
124
+ completedStepCount: 0,
125
+ stepResults: [],
126
+ models: opts.models,
127
+ otomatis: opts.otomatis,
128
+ });
77
129
  }
78
- console.log('');
79
130
  // ② Execute each step in a fresh focused session
131
+ const startFromStep = resume?.startFromStep ?? 0;
132
+ const completedSteps = resume?.stepResults ? [...resume.stepResults] : [];
133
+ if (startFromStep > 0) {
134
+ console.log(yellow(`↩ Melanjutkan dari langkah ${startFromStep + 1}/${plan.langkah.length}`));
135
+ }
80
136
  for (const [i, step] of plan.langkah.entries()) {
137
+ if (i < startFromStep)
138
+ continue; // skip already-completed steps
81
139
  const label = `${i + 1}/${plan.langkah.length}`;
82
140
  console.log(bold(`\n② Pengkode — Langkah ${label}`) + dim(`: ${step.tugas}`));
83
141
  console.log(dim(` 📄 ${step.file.join(', ')}`));
84
142
  const s = newSession(opts.models.pengkode, 'buat', opts.otomatis);
85
- s.messages.push({
86
- role: 'system',
87
- content: [
88
- `Peranmu: PENGKODE — langkah ${label} dari rencana keseluruhan.`,
89
- `TUGAS: ${step.tugas}`,
90
- `File relevan: ${step.file.join(', ')}`,
91
- step.catatan ? `Catatan: ${step.catatan}` : '',
92
- 'Fokus HANYA pada tugas ini. Baca file relevan, buat perubahan, panggil selesai.',
93
- 'Jangan baca file lain kecuali diperlukan langsung untuk memahami konteks.',
94
- ]
95
- .filter(Boolean)
96
- .join('\n'),
97
- });
143
+ s.maxSteps = 30; // executor steps are focused (1-3 files) — keep tight
144
+ appendBrief(s, [
145
+ `Peranmu: PENGKODE — langkah ${label} dari ${plan.langkah.length}.`,
146
+ `TUJUAN KESELURUHAN: ${task.slice(0, 400)}`,
147
+ `RINGKASAN RENCANA: ${plan.ringkasan}`,
148
+ '',
149
+ `TUGAS LANGKAH INI: ${step.tugas}`,
150
+ `File relevan: ${step.file.join(', ')}`,
151
+ step.catatan ? `Catatan: ${step.catatan}` : '',
152
+ '',
153
+ 'Fokus HANYA pada tugas ini. Baca file relevan, buat perubahan, panggil selesai.',
154
+ 'Jangan baca file lain kecuali diperlukan langsung untuk memahami konteks.',
155
+ ]
156
+ .filter(Boolean)
157
+ .join('\n'));
158
+ const previousContext = completedSteps.length > 0
159
+ ? `\n\n[Progress sebelumnya (${completedSteps.length} langkah selesai):\n${completedSteps.slice(-5).join('\n')}]`
160
+ : '';
98
161
  const stepPrompt = [
99
162
  step.tugas,
100
163
  `\nFile: ${step.file.join(', ')}`,
101
164
  step.catatan ? `\nCatatan: ${step.catatan}` : '',
165
+ previousContext,
102
166
  ]
103
167
  .filter(Boolean)
104
168
  .join('');
105
- await runTurn(s, stepPrompt);
169
+ const stepStart = Date.now();
170
+ const stepResult = await runTurn(s, stepPrompt);
171
+ const elapsed = Math.round((Date.now() - stepStart) / 1000);
172
+ const didComplete = s._lastCompleted === true;
173
+ const outcome = stepResult?.trim().slice(0, 500) || '(tidak ada output eksplisit)';
174
+ completedSteps.push(`Langkah ${i + 1} "${step.tugas}" [${didComplete ? '✓' : '⚠'}]: ${outcome}`);
175
+ const statusLine = didComplete
176
+ ? green(`✓ Langkah ${label} selesai`)
177
+ : yellow(`⚠ Langkah ${label} mungkin tidak tuntas`);
178
+ console.log(`${statusLine} ${dim(`(${elapsed}s)`)}`);
179
+ // Persist progress after every step — a crash here loses at most one step
180
+ saveTimCheckpoint({
181
+ task,
182
+ plan,
183
+ completedStepCount: i + 1,
184
+ stepResults: completedSteps,
185
+ models: opts.models,
186
+ otomatis: opts.otomatis,
187
+ });
106
188
  }
107
- // ③ Reviewer — pass the plan so it knows which files were touched
189
+ // ③ Reviewer — receives the plan + step outcomes so it knows what actually happened
108
190
  console.log(`\n${bold('③ Peninjau')} ${dim(`(${opts.models.peninjau})`)}`);
109
- const stepSummary = plan.langkah
110
- .map((s, i) => ` ${i + 1}. ${s.tugas} [${s.file.join(', ')}]`)
111
- .join('\n');
112
191
  const rev = newSession(opts.models.peninjau, 'rencana', opts.otomatis);
113
- rev.messages.push({ role: 'system', content: REVIEWER_BRIEF });
114
- await runTurn(rev, `Tinjau hasil implementasi untuk tugas: ${task}\n\nLangkah yang sudah dikerjakan:\n${stepSummary}`);
192
+ appendBrief(rev, REVIEWER_BRIEF);
193
+ await runTurn(rev, `Tinjau hasil implementasi untuk tugas: ${task}\n\nHasil per langkah:\n${completedSteps.join('\n')}`);
194
+ clearTimCheckpoint();
115
195
  console.log(green('\n✓ Tim selesai.'));
116
196
  }
117
197
  // ---------------------------------------------------------------------------
@@ -144,7 +224,7 @@ export async function runCustomTeam(task, opts) {
144
224
  console.log(`\n${bold(`${String(i + 1).padStart(2)}. ${roleName}`)} ${dim(`(${model}, mode: ${mode})`)}`);
145
225
  const s = newSession(model, mode, otomatis);
146
226
  if (roleCfg.brief)
147
- s.messages.push({ role: 'system', content: roleCfg.brief });
227
+ appendBrief(s, roleCfg.brief);
148
228
  const result = await runTurn(s, context);
149
229
  const trimmed = (result ?? '').trim();
150
230
  const capped = trimmed.length > MAX_ROLE_OUTPUT
package/dist/commands.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { extname } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { parseArgs } from 'node:util';
6
+ import { McpManager } from './agent/mcp.js';
6
7
  import { loadCheckpoint, runAgent } from './agent/run.js';
8
+ import { listSkills, loadSkill } from './agent/skills.js';
7
9
  import { runCustomTeam, runTeam } from './agent/team.js';
8
10
  import { expandAtMentions } from './agent/tools.js';
9
11
  import { createTopup, fetchBytesAuthed, fetchLatestVersion, fetchModels, fetchPemakaian, fetchSaldo, generateImage, modelSupportsTools, pollJob, resolveMediaModel, resolveModel, streamChat, streamVision, submitVideo, } from './api.js';
10
- import { clearKey, fileConfig, initProjectConfig, listTeamConfigs, loadConfig, loadProjectConfig, loadTeamConfig, saveConfig, saveTeamConfig, } from './config.js';
12
+ import { SKILLS_GLOBAL_DIR, clearKey, fileConfig, initProjectConfig, listTeamConfigs, loadConfig, loadMcpServers, loadProjectConfig, loadRawMcpConfig, loadTeamConfig, projectSkillsDir, saveConfig, saveMcpConfig, saveTeamConfig, } from './config.js';
11
13
  import { VERSION } from './help.js';
12
14
  import { bold, cyan, dim, green, idn, prompt, promptHidden, readStdin, red, rupiah, sleep, thinking, yellow, } from './ui.js';
13
15
  const IMAGE_MIME = {
@@ -483,6 +485,219 @@ async function timBuatWizard(nama) {
483
485
  console.log(dim(` Edit : ~/.kiosapi/tim/${nama}.json`));
484
486
  console.log(dim(` Jalankan: kiosapi tim --pakai ${nama} "tugas"`));
485
487
  }
488
+ // ---------------------------------------------------------------------------
489
+ // mcp — manage MCP server connections
490
+ // ---------------------------------------------------------------------------
491
+ const MCP_TEMPLATE = {
492
+ mcpServers: {
493
+ filesystem: {
494
+ command: 'npx',
495
+ args: ['-y', '@modelcontextprotocol/server-filesystem', '.'],
496
+ },
497
+ github: {
498
+ command: 'npx',
499
+ args: ['-y', '@modelcontextprotocol/server-github'],
500
+ env: { GITHUB_TOKEN: 'ghp_YOUR_TOKEN_HERE' },
501
+ },
502
+ },
503
+ };
504
+ /**
505
+ * mcp — list, test, or scaffold MCP server configuration.
506
+ * kiosapi mcp List configured servers
507
+ * kiosapi mcp sambung Connect to all servers and show available tools
508
+ * kiosapi mcp init Scaffold ~/.kiosapi/mcp.json with examples
509
+ */
510
+ export async function cmdMcp(args) {
511
+ const sub = (args[0] ?? '').toLowerCase();
512
+ // kiosapi mcp init — scaffold config file
513
+ if (sub === 'init') {
514
+ const { DIR } = await import('./config.js');
515
+ const path = `${DIR}/mcp.json`;
516
+ const { existsSync } = await import('node:fs');
517
+ if (existsSync(path)) {
518
+ console.log(yellow('~/.kiosapi/mcp.json sudah ada. Edit langsung:'));
519
+ console.log(path);
520
+ console.log(JSON.stringify(loadRawMcpConfig(), null, 2));
521
+ return;
522
+ }
523
+ saveMcpConfig(MCP_TEMPLATE.mcpServers);
524
+ console.log(green('✓ ~/.kiosapi/mcp.json dibuat'));
525
+ console.log(dim(' Edit untuk sesuaikan server dan path, lalu jalankan: kiosapi mcp sambung'));
526
+ console.log(JSON.stringify(MCP_TEMPLATE, null, 2));
527
+ return;
528
+ }
529
+ const servers = loadMcpServers();
530
+ const serverCount = Object.keys(servers).length;
531
+ // kiosapi mcp — list configured servers (no connection)
532
+ if (!sub || sub === 'daftar' || sub === 'list') {
533
+ if (serverCount === 0) {
534
+ console.log(dim('Belum ada MCP server dikonfigurasi.'));
535
+ console.log(dim(' Scaffold: kiosapi mcp init'));
536
+ console.log(dim(' Config : ~/.kiosapi/mcp.json atau kiosapi.json → mcpServers'));
537
+ return;
538
+ }
539
+ console.log(bold(`MCP Servers (${serverCount}):`));
540
+ for (const [name, cfg] of Object.entries(servers)) {
541
+ const cmd = [cfg.command, ...(cfg.args ?? [])].join(' ');
542
+ console.log(` ${cyan(name)} ${dim(cmd)}`);
543
+ }
544
+ console.log(dim('\nTest koneksi: kiosapi mcp sambung'));
545
+ return;
546
+ }
547
+ // kiosapi mcp sambung — connect and list tools
548
+ if (sub === 'sambung' || sub === 'connect' || sub === 'test') {
549
+ if (serverCount === 0) {
550
+ console.log(dim('Belum ada MCP server dikonfigurasi. Jalankan: kiosapi mcp init'));
551
+ return;
552
+ }
553
+ console.log(dim(`Menghubungkan ke ${serverCount} server MCP…`));
554
+ const mgr = new McpManager();
555
+ try {
556
+ await mgr.connect(servers, (warn) => console.log(yellow(warn)));
557
+ if (mgr.toolCount === 0) {
558
+ console.log(yellow('Tidak ada tools yang tersedia dari server yang terhubung.'));
559
+ return;
560
+ }
561
+ console.log(green(`\n✓ ${mgr.serverCount} server · ${mgr.toolCount} tools\n`));
562
+ for (const { server, tools } of mgr.summary()) {
563
+ console.log(bold(` ${server}`));
564
+ for (const t of tools) {
565
+ const desc = (t.spec.function.description ?? '').replace(`[MCP:${server}] `, '');
566
+ console.log(` ${cyan(t.toolName)} ${dim(desc.slice(0, 70))}`);
567
+ }
568
+ }
569
+ console.log(dim('\nTools ini tersedia otomatis di semua sesi agen.'));
570
+ }
571
+ finally {
572
+ mgr.disconnect();
573
+ }
574
+ return;
575
+ }
576
+ console.log(red(`Sub-perintah tidak dikenal: mcp ${sub}`));
577
+ console.log(dim(' kiosapi mcp — lihat server dikonfigurasi'));
578
+ console.log(dim(' kiosapi mcp sambung — test koneksi + lihat tools'));
579
+ console.log(dim(' kiosapi mcp init — scaffold ~/.kiosapi/mcp.json'));
580
+ }
581
+ // ---------------------------------------------------------------------------
582
+ // skill — reusable saved agent workflows
583
+ // ---------------------------------------------------------------------------
584
+ const SKILL_TEMPLATE = `---
585
+ mode: buat
586
+ # otomatis: false
587
+ # model: deepseek/deepseek-v4-flash
588
+ description: Deskripsi singkat skill ini
589
+ ---
590
+ Tulis prompt skillmu di sini.
591
+
592
+ Tips — gunakan @-mention untuk menyertakan konteks:
593
+ @git:diff git diff saat ini
594
+ @git:status status working tree
595
+ @src/**/*.ts semua file .ts di src/
596
+ @path/to/file.ts file spesifik
597
+
598
+ Contoh:
599
+ Review @git:diff, identifikasi masalah potensial, dan buat laporan ringkas.
600
+ `;
601
+ /** Try to open a file in the user's preferred editor; falls back to printing the path. */
602
+ function openInEditor(filePath) {
603
+ const editor = process.env.EDITOR || process.env.VISUAL;
604
+ if (!editor) {
605
+ console.log(dim(` Buka di editor: ${filePath}`));
606
+ return;
607
+ }
608
+ const res = spawnSync(editor, [filePath], { stdio: 'inherit' });
609
+ if (res.error)
610
+ console.log(dim(` Edit manual: ${filePath}`));
611
+ }
612
+ /**
613
+ * skill — create, list, or run saved skill files.
614
+ * kiosapi skill buat [nama] Buat skill baru (project scope)
615
+ * kiosapi skill buat --global [nama] Buat skill di ~/.kiosapi/skills/
616
+ * kiosapi skill <nama> [konteks] Jalankan skill
617
+ * kiosapi skills Sama dengan kiosapi skill --daftar
618
+ */
619
+ export async function cmdSkill(args) {
620
+ const sub = args[0]?.toLowerCase();
621
+ // kiosapi skill buat [--global] [nama]
622
+ if (sub === 'buat' || sub === 'create' || sub === 'new') {
623
+ const rest = args.slice(1);
624
+ const isGlobal = rest[0] === '--global' || rest[0] === '-g';
625
+ const nameArg = isGlobal ? rest[1] : rest[0];
626
+ const name = nameArg?.trim() || (await prompt('Nama skill (huruf kecil, tanda hubung): ')).trim();
627
+ if (!name)
628
+ throw new Error('Nama skill tidak boleh kosong.');
629
+ if (!/^[\w-]+$/.test(name))
630
+ throw new Error('Nama hanya boleh huruf, angka, dan tanda hubung.');
631
+ const dir = isGlobal ? SKILLS_GLOBAL_DIR : projectSkillsDir();
632
+ mkdirSync(dir, { recursive: true });
633
+ const filePath = `${dir}/${name}.md`;
634
+ writeFileSync(filePath, SKILL_TEMPLATE);
635
+ const scope = isGlobal ? 'global (~/.kiosapi/skills/)' : 'project (.kiosapi/skills/)';
636
+ console.log(green(`\n✓ Skill "${name}" dibuat (${scope})`));
637
+ console.log(dim(` File: ${filePath}`));
638
+ console.log(dim(` Jalankan: kiosapi skill ${name}`));
639
+ console.log(dim(' Edit prompt & frontmatter lalu simpan.\n'));
640
+ openInEditor(filePath);
641
+ return;
642
+ }
643
+ // kiosapi skill --daftar | kiosapi skills
644
+ if (!sub || sub === '--daftar' || sub === '--list' || sub === 'daftar' || sub === 'list') {
645
+ return cmdSkills();
646
+ }
647
+ // kiosapi skill <nama> [-m model] [--otomatis] [extra context…]
648
+ const skillName = sub;
649
+ const { values: flagValues, positionals: extraPositionals } = parseArgs({
650
+ args: args.slice(1),
651
+ options: { model: { type: 'string', short: 'm' }, otomatis: { type: 'boolean' } },
652
+ allowPositionals: true,
653
+ strict: false,
654
+ });
655
+ const extraArgs = extraPositionals.join(' ').trim();
656
+ const skill = loadSkill(skillName);
657
+ if (!skill) {
658
+ console.error(red(`Skill "${skillName}" tidak ditemukan.`));
659
+ console.log(dim(' Buat baru: kiosapi skill buat <nama>'));
660
+ console.log(dim(' Lihat daftar: kiosapi skills'));
661
+ process.exitCode = 1;
662
+ return;
663
+ }
664
+ const cfg = loadConfig();
665
+ const model = flagValues.model ?? skill.model ?? cfg.defaultModel;
666
+ const resolvedModel = await resolveModel(model);
667
+ const otomatis = flagValues.otomatis ?? skill.otomatis;
668
+ const scopeLabel = skill.source === 'project' ? dim(' [project]') : dim(' [global]');
669
+ console.log(`${bold(`▶ ${skill.name}`)}${scopeLabel}${skill.description ? ` — ${dim(skill.description)}` : ''}`);
670
+ const prompt_ = extraArgs ? `${skill.prompt}\n\n${extraArgs}` : skill.prompt;
671
+ const expanded = await expandAtMentions(prompt_);
672
+ await runAgent(expanded, skill.mode, { model: resolvedModel, otomatis });
673
+ }
674
+ /** skills — list all available skills. */
675
+ export async function cmdSkills() {
676
+ const all = listSkills();
677
+ if (all.length === 0) {
678
+ console.log(dim('Belum ada skill.'));
679
+ console.log(dim(' Buat baru: kiosapi skill buat <nama>'));
680
+ return;
681
+ }
682
+ const projectSkills = all.filter((s) => s.source === 'project');
683
+ const globalSkills = all.filter((s) => s.source === 'global');
684
+ if (projectSkills.length > 0) {
685
+ console.log(bold('\nSkill project (.kiosapi/skills/):'));
686
+ for (const sk of projectSkills) {
687
+ const desc = sk.description ? dim(` — ${sk.description}`) : '';
688
+ console.log(` ${cyan(sk.name)}${desc} ${dim(`[${sk.mode}]`)}`);
689
+ }
690
+ }
691
+ if (globalSkills.length > 0) {
692
+ console.log(bold('\nSkill global (~/.kiosapi/skills/):'));
693
+ for (const sk of globalSkills) {
694
+ const desc = sk.description ? dim(` — ${sk.description}`) : '';
695
+ console.log(` ${cyan(sk.name)}${desc} ${dim(`[${sk.mode}]`)}`);
696
+ }
697
+ }
698
+ console.log(dim('\nJalankan: kiosapi skill <nama>'));
699
+ console.log(dim('Buat baru: kiosapi skill buat <nama>'));
700
+ }
486
701
  const ALL_COMMANDS = [
487
702
  'masuk',
488
703
  'keluar',
@@ -505,6 +720,9 @@ const ALL_COMMANDS = [
505
720
  'lihat',
506
721
  'lanjut',
507
722
  'init',
723
+ 'skill',
724
+ 'skills',
725
+ 'mcp',
508
726
  'completion',
509
727
  // English aliases
510
728
  'login',
@@ -637,6 +855,8 @@ export async function cmdTim(args) {
637
855
  const task = positionals.join(' ').trim();
638
856
  if (!task)
639
857
  throw new Error('Beri tugas. Contoh: kiosapi tim "bikin endpoint /health + tes"');
858
+ // Expand @file mentions so users can pass: kiosapi tim "@blueprint.md"
859
+ const expandedTask = await expandAtMentions(task);
640
860
  const base = await resolveModel(values.model);
641
861
  const models = {
642
862
  perencana: values.perencana ?? base,
@@ -645,7 +865,7 @@ export async function cmdTim(args) {
645
865
  };
646
866
  for (const m of new Set(Object.values(models)))
647
867
  await warnIfNoTools(m);
648
- await runTeam(task, { models, otomatis: Boolean(values.otomatis) });
868
+ await runTeam(expandedTask, { models, otomatis: Boolean(values.otomatis) });
649
869
  }
650
870
  /** saldo — show own balance, bonus tokens, and month-to-date spend. */
651
871
  export async function cmdSaldo() {
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  const DEFAULT_BASE_URL = 'https://api.kiosapi.id';
@@ -6,6 +6,13 @@ export const DIR = join(homedir(), '.kiosapi');
6
6
  export const CONFIG_PATH = join(DIR, 'config.json');
7
7
  export const CHECKPOINT_PATH = join(DIR, 'checkpoint.json');
8
8
  export const TIM_DIR = join(DIR, 'tim');
9
+ export const TIM_CHECKPOINT_PATH = join(DIR, 'tim-checkpoint.json');
10
+ export const SKILLS_GLOBAL_DIR = join(DIR, 'skills');
11
+ const MCP_CONFIG_PATH = join(DIR, 'mcp.json');
12
+ /** Returns the project-level skills directory (.kiosapi/skills/ under cwd). */
13
+ export function projectSkillsDir() {
14
+ return join(process.cwd(), '.kiosapi', 'skills');
15
+ }
9
16
  /** Read only the on-disk config (ignores env overrides) — used before writing. */
10
17
  function readFile() {
11
18
  if (!existsSync(CONFIG_PATH))
@@ -96,6 +103,71 @@ export function initProjectConfig(config) {
96
103
  writeFileSync(p, `${JSON.stringify(config, null, 2)}\n`);
97
104
  return p;
98
105
  }
106
+ export function saveTimCheckpoint(data) {
107
+ mkdirSync(DIR, { recursive: true });
108
+ const full = {
109
+ type: 'tim',
110
+ ...data,
111
+ savedAt: new Date().toISOString(),
112
+ cwd: process.cwd(),
113
+ };
114
+ writeFileSync(TIM_CHECKPOINT_PATH, `${JSON.stringify(full, null, 2)}\n`);
115
+ }
116
+ export function loadTimCheckpoint() {
117
+ if (!existsSync(TIM_CHECKPOINT_PATH))
118
+ return null;
119
+ try {
120
+ const data = JSON.parse(readFileSync(TIM_CHECKPOINT_PATH, 'utf8'));
121
+ if (data.type !== 'tim')
122
+ return null;
123
+ return data;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ export function clearTimCheckpoint() {
130
+ try {
131
+ unlinkSync(TIM_CHECKPOINT_PATH);
132
+ }
133
+ catch {
134
+ // already gone
135
+ }
136
+ }
137
+ /**
138
+ * Load all MCP server configs: global (~/.kiosapi/mcp.json) merged with project (kiosapi.json).
139
+ * Project entries override global ones on name collision.
140
+ */
141
+ export function loadMcpServers() {
142
+ let global = {};
143
+ if (existsSync(MCP_CONFIG_PATH)) {
144
+ try {
145
+ const data = JSON.parse(readFileSync(MCP_CONFIG_PATH, 'utf8'));
146
+ global = data.mcpServers ?? {};
147
+ }
148
+ catch {
149
+ /* corrupt file — ignore */
150
+ }
151
+ }
152
+ const project = loadProjectConfig()?.mcpServers ?? {};
153
+ return { ...global, ...project };
154
+ }
155
+ /** Write the global MCP config file. */
156
+ export function saveMcpConfig(servers) {
157
+ mkdirSync(DIR, { recursive: true });
158
+ writeFileSync(MCP_CONFIG_PATH, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
159
+ }
160
+ /** Read the raw global MCP config (for display / editing). */
161
+ export function loadRawMcpConfig() {
162
+ if (!existsSync(MCP_CONFIG_PATH))
163
+ return { mcpServers: {} };
164
+ try {
165
+ return JSON.parse(readFileSync(MCP_CONFIG_PATH, 'utf8'));
166
+ }
167
+ catch {
168
+ return { mcpServers: {} };
169
+ }
170
+ }
99
171
  /** List all saved team names. */
100
172
  export function listTeamConfigs() {
101
173
  if (!existsSync(TIM_DIR))
package/dist/help.js CHANGED
@@ -47,6 +47,20 @@ ${bold('Agen (butuh model 🔧):')}
47
47
  init Buat kiosapi.json (config per-project: model, mode, otomatis)
48
48
  ${dim('Opsi: -m <model>, --otomatis (lewati konfirmasi). Mendukung pipe stdin.')}
49
49
 
50
+ ${bold('Skills (workflow tersimpan):')}
51
+ skill buat <nama> Buat skill baru di project (.kiosapi/skills/)
52
+ skill buat --global <n> Buat skill global (~/.kiosapi/skills/)
53
+ skill <nama> [konteks] Jalankan skill; konteks opsional ditambahkan ke prompt
54
+ skills Lihat semua skill tersimpan (project + global)
55
+ ${dim('Dalam sesi: /skills · /skill <nama> [konteks]')}
56
+
57
+ ${bold('MCP (Model Context Protocol):')}
58
+ mcp Lihat server MCP dikonfigurasi
59
+ mcp sambung Test koneksi + lihat tools tersedia
60
+ mcp init Buat ~/.kiosapi/mcp.json dengan contoh konfigurasi
61
+ ${dim('Config: ~/.kiosapi/mcp.json (global) atau kiosapi.json → mcpServers (per-project)')}
62
+ ${dim('Tools MCP otomatis tersedia di semua sesi agen dan tim mode.')}
63
+
50
64
  ${bold('Tim multi-agen:')}
51
65
  tim "…" Jalankan tim bawaan (perencana → pengkode → peninjau)
52
66
  tim --pakai <nama> "…" Jalankan tim kustom tersimpan
@@ -72,6 +86,11 @@ ${bold('Contoh:')}
72
86
  kiosapi lanjut
73
87
  kiosapi tim --buat fullstack-team
74
88
  kiosapi tim --pakai fullstack-team "bikin halaman login"
89
+ kiosapi skill buat fix-tests
90
+ kiosapi skill fix-tests
91
+ kiosapi skills
92
+ kiosapi mcp init
93
+ kiosapi mcp sambung
75
94
  kiosapi completion bash >> ~/.bashrc
76
95
 
77
96
  ${dim('Sesi interaktif: /undo batalkan giliran terakhir · /ringkas padatkan riwayat · /model ganti model.')}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { cmdBuat, cmdCompletion, cmdEdit, cmdGambar, cmdInit, cmdIsi, cmdKeluar, cmdLihat, cmdMasuk, cmdModel, cmdNgobrol, cmdPakai, cmdPerbarui, cmdPeriksa, cmdRencana, cmdSaldo, cmdSambung, cmdSetel, cmdTanya, cmdTim, cmdVideo, maybeNotifyUpdate, } from './commands.js';
2
+ import { cmdBuat, cmdCompletion, cmdEdit, cmdGambar, cmdInit, cmdIsi, cmdKeluar, cmdLihat, cmdMasuk, cmdMcp, cmdModel, cmdNgobrol, cmdPakai, cmdPerbarui, cmdPeriksa, cmdRencana, cmdSaldo, cmdSambung, cmdSetel, cmdSkill, cmdSkills, cmdTanya, cmdTim, cmdVideo, maybeNotifyUpdate, } from './commands.js';
3
3
  import { printHelp, printVersion } from './help.js';
4
4
  import { resumeFromCheckpoint, startSession } from './session.js';
5
5
  import { red } from './ui.js';
@@ -44,6 +44,9 @@ const COMMANDS = {
44
44
  lanjut: resumeFromCheckpoint,
45
45
  resume: resumeFromCheckpoint,
46
46
  init: cmdInit,
47
+ skill: cmdSkill,
48
+ skills: cmdSkills,
49
+ mcp: cmdMcp,
47
50
  completion: cmdCompletion,
48
51
  };
49
52
  async function main() {
package/dist/session.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { McpManager } from './agent/mcp.js';
2
3
  import { clearCheckpoint, loadCheckpoint, newSession, resetSession, runTurn, undoLastTurn, } from './agent/run.js';
4
+ import { listSkills, loadSkill } from './agent/skills.js';
3
5
  import { runCustomTeam, runTeam } from './agent/team.js';
4
6
  import { expandAtMentions } from './agent/tools.js';
5
7
  import { resolveModel, streamChat } from './api.js';
6
- import { cmdGambar, cmdIsi, cmdLihat, cmdMasuk, cmdPakai, cmdPerbarui, cmdSaldo, cmdVideo, maybeNotifyUpdate, pickModel, warnIfNoTools, } from './commands.js';
7
- import { listTeamConfigs, loadConfig, loadProjectConfig, loadTeamConfig, saveTeamConfig, } from './config.js';
8
+ import { cmdGambar, cmdIsi, cmdLihat, cmdMasuk, cmdPakai, cmdPerbarui, cmdSaldo, cmdSkill, cmdVideo, maybeNotifyUpdate, pickModel, warnIfNoTools, } from './commands.js';
9
+ import { clearTimCheckpoint, listTeamConfigs, loadConfig, loadMcpServers, loadProjectConfig, loadTeamConfig, loadTimCheckpoint, saveTeamConfig, } from './config.js';
8
10
  import { bold, cyan, dim, green, idn, prompt, red, thinking, yellow } from './ui.js';
9
11
  const MODES = ['rencana', 'edit', 'buat'];
10
12
  /** The prompt indicator shows the active mode, turn count, accumulated tokens, and ⚡ for auto. */
@@ -23,6 +25,8 @@ ${dim('Ketik tugasmu langsung. Perintah meta diawali "/". /bantuan untuk daftar,
23
25
  }
24
26
  function slashHelp() {
25
27
  console.log(`${bold('Perintah sesi:')}
28
+ /skills Lihat semua skill tersimpan
29
+ /skill <nama> [konteks] Jalankan skill (project atau global)
26
30
  /tim <tugas> Multi-agen bawaan (perencana→pengkode→peninjau)
27
31
  /tim --pakai <nama> <tugas> Jalankan tim kustom tersimpan
28
32
  /peran [peran] [model] Atur model per peran tim (atau lihat/reset)
@@ -65,6 +69,64 @@ async function runSlash(line, s) {
65
69
  case '?':
66
70
  slashHelp();
67
71
  return false;
72
+ case 'skills':
73
+ case 'skill-daftar': {
74
+ const all = listSkills();
75
+ if (all.length === 0) {
76
+ console.log(dim('Belum ada skill. Buat dengan: kiosapi skill buat <nama>'));
77
+ }
78
+ else {
79
+ for (const sk of all) {
80
+ const scopeTag = sk.source === 'project' ? '[project]' : '[global]';
81
+ const desc = sk.description ? ` — ${sk.description}` : '';
82
+ console.log(` ${cyan(sk.name)} ${dim(scopeTag)}${dim(desc)} ${dim(`[${sk.mode}]`)}`);
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ case 'skill': {
88
+ const skillName = rest[0];
89
+ if (!skillName) {
90
+ // No name → show list
91
+ const all = listSkills();
92
+ if (all.length === 0) {
93
+ console.log(dim('Belum ada skill. Buat dengan: kiosapi skill buat <nama>'));
94
+ }
95
+ else {
96
+ for (const sk of all) {
97
+ const desc = sk.description ? dim(` — ${sk.description}`) : '';
98
+ console.log(` ${cyan(sk.name)}${desc} ${dim(`[${sk.mode}]`)}`);
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+ const sk = loadSkill(skillName);
104
+ if (!sk) {
105
+ console.log(red(`Skill "${skillName}" tidak ditemukan.`));
106
+ console.log(dim(' Buat: kiosapi skill buat <nama> | Lihat: /skills'));
107
+ return false;
108
+ }
109
+ const extra = rest.slice(1).join(' ').trim();
110
+ const rawPrompt = extra ? `${sk.prompt}\n\n${extra}` : sk.prompt;
111
+ const expanded = await expandAtMentions(rawPrompt);
112
+ // Apply skill's model/mode/otomatis to the current session for this turn only
113
+ const prevModel = s.model;
114
+ const prevMode = s.mode;
115
+ const prevOto = s.otomatis;
116
+ if (sk.model)
117
+ s.model = sk.model;
118
+ s.mode = sk.mode;
119
+ if (sk.otomatis)
120
+ s.otomatis = true;
121
+ const scopeLabel = sk.source === 'project' ? dim(' [project]') : dim(' [global]');
122
+ console.log(`${bold(`▶ skill: ${sk.name}`)}${scopeLabel}`);
123
+ await runTurn(s, expanded);
124
+ // Restore session settings after skill run
125
+ s.model = prevModel;
126
+ s.mode = prevMode;
127
+ s.otomatis = prevOto;
128
+ return false;
129
+ }
68
130
  case 'mode': {
69
131
  const m = rest[0]?.toLowerCase();
70
132
  if (!m) {
@@ -483,7 +545,22 @@ export async function startSession() {
483
545
  }
484
546
  banner(s);
485
547
  await warnIfNoTools(model);
486
- await runSessionLoop(s);
548
+ const mcpServers = loadMcpServers();
549
+ let mcpManager;
550
+ if (Object.keys(mcpServers).length > 0) {
551
+ mcpManager = new McpManager();
552
+ await mcpManager.connect(mcpServers, (w) => console.log(yellow(w)));
553
+ if (mcpManager.toolCount > 0) {
554
+ console.log(dim(` MCP: ${mcpManager.toolCount} tool dari ${mcpManager.serverCount} server`));
555
+ }
556
+ s.mcpManager = mcpManager;
557
+ }
558
+ try {
559
+ await runSessionLoop(s);
560
+ }
561
+ finally {
562
+ mcpManager?.disconnect();
563
+ }
487
564
  }
488
565
  /**
489
566
  * Resume a checkpointed session interactively: load the saved state, show a preamble, ask for a
@@ -492,6 +569,51 @@ export async function startSession() {
492
569
  */
493
570
  export async function resumeFromCheckpoint(args) {
494
571
  await maybeNotifyUpdate();
572
+ // Tim checkpoint takes priority — it stores multi-step pipeline progress
573
+ const timCp = loadTimCheckpoint();
574
+ if (timCp) {
575
+ console.log(bold('Sesi tim tersimpan ditemukan'));
576
+ console.log(` Tugas : ${timCp.task.slice(0, 80)}${timCp.task.length > 80 ? '…' : ''}`);
577
+ console.log(` Progress : ${timCp.completedStepCount}/${timCp.plan.langkah.length} langkah selesai`);
578
+ console.log(` Disimpan : ${new Date(timCp.savedAt).toLocaleString('id-ID')}`);
579
+ if (timCp.completedStepCount > 0) {
580
+ console.log(dim(' Langkah selesai:'));
581
+ for (const [i, step] of timCp.plan.langkah.entries()) {
582
+ if (i >= timCp.completedStepCount)
583
+ break;
584
+ console.log(dim(` ✓ ${i + 1}. ${step.tugas}`));
585
+ }
586
+ }
587
+ const remaining = timCp.plan.langkah.length - timCp.completedStepCount;
588
+ console.log(`\n Sisa : ${remaining} langkah belum dikerjakan`);
589
+ if (timCp.cwd !== process.cwd()) {
590
+ console.log(yellow(`\n⚠ Sesi ini disimpan di direktori berbeda: ${timCp.cwd}`));
591
+ }
592
+ if (!loadConfig().apiKey) {
593
+ console.log(dim('Belum masuk — masukkan API key dulu.'));
594
+ await cmdMasuk();
595
+ if (!loadConfig().apiKey)
596
+ return;
597
+ }
598
+ const ans = (await prompt('Lanjutkan sesi tim ini? (y/t) ')).trim().toLowerCase();
599
+ if (ans === 'y' || ans === 'ya') {
600
+ const resume = {
601
+ startFromStep: timCp.completedStepCount,
602
+ stepResults: timCp.stepResults,
603
+ plan: timCp.plan,
604
+ };
605
+ await runTeam(timCp.task, { models: timCp.models, otomatis: timCp.otomatis }, resume);
606
+ }
607
+ else {
608
+ console.log(dim('Sesi tim dibatalkan.'));
609
+ const clearAns = (await prompt('Hapus checkpoint tim ini? (y/t) ')).trim().toLowerCase();
610
+ if (clearAns === 'y' || clearAns === 'ya') {
611
+ clearTimCheckpoint();
612
+ console.log(dim('Checkpoint tim dihapus.'));
613
+ }
614
+ }
615
+ return;
616
+ }
495
617
  const cp = loadCheckpoint();
496
618
  if (!cp) {
497
619
  throw new Error('Tidak ada sesi tersimpan. Mulai dengan: kiosapi buat "tugas"\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiosapi",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "description": "CLI Kiosapi.id berbahasa Indonesia — bangun aplikasimu pakai API key Kiosapi (agen + multimodal).",
6
6
  "keywords": [