kiosapi 0.1.7 → 0.1.9

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/run.js CHANGED
@@ -1,9 +1,199 @@
1
- import { writeFileSync } from 'node:fs';
2
- import { chatComplete, generateImage, resolveMediaModel, } from '../api.js';
3
- import { cyan, dim, green, prompt, rupiah, thinking, yellow } from '../ui.js';
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { extname } from 'node:path';
3
+ import { chatComplete, generateImage, resolveMediaModel, streamVision, } from '../api.js';
4
+ import { CHECKPOINT_PATH } from '../config.js';
5
+ import { cyan, dim, green, idn, prompt, red, rupiah, thinking, yellow } from '../ui.js';
4
6
  import { systemPrompt, toolsForMode } from './schemas.js';
5
- import { bacaFile, cari, daftarFile, editFile, jalankan, tulisFile } from './tools.js';
6
- const MAX_STEPS = 25;
7
+ import { bacaFile, cari, daftarFile, editFile, hapusFile, jalankan, pindahFile, safePath, tulisFile, } from './tools.js';
8
+ const MAX_STEPS = 50;
9
+ // ---------------------------------------------------------------------------
10
+ // Context trimming: bound what is sent to the API (and stored in checkpoint)
11
+ // ---------------------------------------------------------------------------
12
+ /**
13
+ * How many messages (excluding the system prompt) to keep when trimming. Each agent "step" is
14
+ * roughly 1 assistant + 1-3 tool results, so 40 messages ≈ 10-15 full steps of recent history —
15
+ * enough for continuity while preventing multi-MB payloads on long sessions.
16
+ */
17
+ const MAX_CONTEXT_MESSAGES = 40;
18
+ /**
19
+ * Return a context-bounded slice of the message history. Never mutates the input.
20
+ *
21
+ * The kept slice always:
22
+ * - starts with the original system prompt
23
+ * - contains at most MAX_CONTEXT_MESSAGES non-system messages
24
+ * - begins on a `user` turn boundary (never mid tool-call sequence, which providers reject)
25
+ * - prepends a system note so the model knows early context was dropped
26
+ *
27
+ * Used both before every chatComplete call (to cap API payload) and when writing the checkpoint
28
+ * (to keep checkpoint.json small — a resumed session gets the same bounded window anyway).
29
+ */
30
+ function trimContext(messages) {
31
+ if (messages.length === 0)
32
+ return messages;
33
+ const [system, ...rest] = messages;
34
+ if (rest.length <= MAX_CONTEXT_MESSAGES)
35
+ return messages;
36
+ // Take the tail we want to keep, then advance past any leading tool/assistant messages so
37
+ // the slice always starts on a complete user turn. Orphaned tool results (whose paired
38
+ // assistant message was trimmed) cause provider errors on the next API call.
39
+ let tail = rest.slice(-MAX_CONTEXT_MESSAGES);
40
+ let skip = 0;
41
+ while (skip < tail.length && tail[skip]?.role !== 'user')
42
+ skip++;
43
+ tail = tail.slice(skip);
44
+ const dropped = rest.length - tail.length;
45
+ if (dropped <= 0)
46
+ return messages; // nothing actually trimmed
47
+ const note = {
48
+ role: 'system',
49
+ content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks untuk menghemat token. Lanjutkan dari konteks terkini di bawah.]`,
50
+ };
51
+ return [system, note, ...tail];
52
+ }
53
+ function saveCheckpoint(s) {
54
+ try {
55
+ const data = {
56
+ model: s.model,
57
+ mode: s.mode,
58
+ otomatis: s.otomatis,
59
+ // Trim before persisting: a resumed session gets the same bounded window the API would
60
+ // have seen anyway, and the file stays small even after many baca_file calls.
61
+ messages: trimContext(s.messages),
62
+ teamModels: s.teamModels,
63
+ cwd: process.cwd(),
64
+ savedAt: new Date().toISOString(),
65
+ totalTokens: s.totalTokens,
66
+ maxSteps: s.maxSteps,
67
+ };
68
+ writeFileSync(CHECKPOINT_PATH, `${JSON.stringify(data, null, 2)}\n`);
69
+ }
70
+ catch {
71
+ // best-effort — never crash the agent loop over a checkpoint failure
72
+ }
73
+ }
74
+ /** Load the last auto-saved session, or null if none exists. */
75
+ export function loadCheckpoint() {
76
+ if (!existsSync(CHECKPOINT_PATH))
77
+ return null;
78
+ try {
79
+ const data = JSON.parse(readFileSync(CHECKPOINT_PATH, 'utf8'));
80
+ return {
81
+ ...data,
82
+ teamModels: data.teamModels ?? {},
83
+ totalTokens: data.totalTokens ?? 0,
84
+ };
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ /** Remove the checkpoint file (call when task is fully done or user starts fresh). */
91
+ export function clearCheckpoint() {
92
+ try {
93
+ unlinkSync(CHECKPOINT_PATH);
94
+ }
95
+ catch {
96
+ // already gone — fine
97
+ }
98
+ }
99
+ function computeLineDiff(oldLines, newLines) {
100
+ const m = oldLines.length;
101
+ const n = newLines.length;
102
+ // Guard against O(m*n) blowout on very large text blocks
103
+ if (m * n > 400_000) {
104
+ return [
105
+ ...oldLines.map((l) => ({ op: '-', line: l })),
106
+ ...newLines.map((l) => ({ op: '+', line: l })),
107
+ ];
108
+ }
109
+ // LCS DP: dp[i][j] = LCS length of oldLines[i:] vs newLines[j:]
110
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
111
+ for (let i = m - 1; i >= 0; i--) {
112
+ for (let j = n - 1; j >= 0; j--) {
113
+ if (oldLines[i] === newLines[j]) {
114
+ dp[i][j] = 1 + (dp[i + 1]?.[j + 1] ?? 0);
115
+ }
116
+ else {
117
+ dp[i][j] = Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
118
+ }
119
+ }
120
+ }
121
+ // Traceback: prefer delete before insert (del-then-ins reads more naturally)
122
+ const ops = [];
123
+ let i = 0;
124
+ let j = 0;
125
+ while (i < m || j < n) {
126
+ if (i < m && j < n && oldLines[i] === newLines[j]) {
127
+ ops.push({ op: '=', line: oldLines[i] });
128
+ i++;
129
+ j++;
130
+ }
131
+ else if (j < n && (i >= m || (dp[i + 1]?.[j] ?? 0) < (dp[i]?.[j + 1] ?? 0))) {
132
+ ops.push({ op: '+', line: newLines[j] });
133
+ j++;
134
+ }
135
+ else {
136
+ ops.push({ op: '-', line: oldLines[i] });
137
+ i++;
138
+ }
139
+ }
140
+ return ops;
141
+ }
142
+ const DIFF_CONTEXT = 3;
143
+ function showEditDiff(filePath, oldText, newText) {
144
+ const ops = computeLineDiff(oldText.split('\n'), newText.split('\n'));
145
+ // Mark lines to show: every changed line plus DIFF_CONTEXT around it
146
+ const show = new Set();
147
+ for (let k = 0; k < ops.length; k++) {
148
+ if (ops[k].op !== '=') {
149
+ for (let c = Math.max(0, k - DIFF_CONTEXT); c <= Math.min(ops.length - 1, k + DIFF_CONTEXT); c++) {
150
+ show.add(c);
151
+ }
152
+ }
153
+ }
154
+ if (show.size === 0) {
155
+ console.log(dim(`\n diff ${filePath}: tidak ada perubahan`));
156
+ return;
157
+ }
158
+ console.log(dim(`\n diff ${filePath}`));
159
+ let lastShown = -1;
160
+ for (let k = 0; k < ops.length; k++) {
161
+ if (!show.has(k))
162
+ continue;
163
+ if (lastShown >= 0 && k > lastShown + 1)
164
+ console.log(dim(' ···'));
165
+ const { op, line } = ops[k];
166
+ if (op === '-')
167
+ console.log(red(` - ${line}`));
168
+ else if (op === '+')
169
+ console.log(green(` + ${line}`));
170
+ else
171
+ console.log(dim(` ${line}`));
172
+ lastShown = k;
173
+ }
174
+ console.log('');
175
+ }
176
+ function showFilePreview(filePath, konten) {
177
+ const lines = konten.split('\n');
178
+ const preview = lines.slice(0, 10);
179
+ console.log(dim(`\n +++ ${filePath}`));
180
+ for (const l of preview)
181
+ console.log(green(` + ${l}`));
182
+ if (lines.length > 10)
183
+ console.log(dim(` … ${lines.length - 10} baris lagi`));
184
+ console.log('');
185
+ }
186
+ /**
187
+ * After a `jalankan` call, output was already streamed live to stdout. This just adds a red
188
+ * exit-code marker so failures are clearly flagged at the bottom of the output block.
189
+ */
190
+ function showCommandOutput(out) {
191
+ const firstNl = out.indexOf('\n');
192
+ const exitLine = firstNl === -1 ? out : out.slice(0, firstNl);
193
+ const failed = exitLine.startsWith('exit=') && exitLine !== 'exit=0' && exitLine !== 'exit=null';
194
+ if (failed)
195
+ console.log(red(` ✗ ${exitLine}`));
196
+ }
7
197
  /** Ask y/t before a destructive action (unless --otomatis). */
8
198
  async function allow(otomatis, question) {
9
199
  if (otomatis)
@@ -11,8 +201,15 @@ async function allow(otomatis, question) {
11
201
  const ans = (await prompt(`${yellow('? ')}${question} (y/t) `)).trim().toLowerCase();
12
202
  return ans === 'y' || ans === 'ya';
13
203
  }
204
+ const IMAGE_MIME = {
205
+ '.png': 'image/png',
206
+ '.jpg': 'image/jpeg',
207
+ '.jpeg': 'image/jpeg',
208
+ '.webp': 'image/webp',
209
+ '.gif': 'image/gif',
210
+ };
14
211
  /** Execute one tool call (with permission gating for destructive tools). */
15
- async function runTool(call, otomatis) {
212
+ async function runTool(call, otomatis, model) {
16
213
  let args = {};
17
214
  try {
18
215
  args = JSON.parse(call.function.arguments || '{}');
@@ -36,16 +233,36 @@ async function runTool(call, otomatis) {
36
233
  const p = str('path');
37
234
  const isi = str('konten');
38
235
  console.log(` ${cyan('tulis')} ${p} ${dim(`(${isi.length} char)`)}`);
236
+ showFilePreview(p, isi);
39
237
  if (!(await allow(otomatis, `Tulis file ${p}?`)))
40
238
  return { output: 'Ditolak oleh pengguna.' };
41
- return { output: tulisFile(p, isi) };
239
+ return { output: tulisFile(p, isi), modifiedPath: p };
42
240
  }
43
241
  case 'edit_file': {
44
242
  const p = str('path');
45
243
  console.log(` ${cyan('edit')} ${p}`);
244
+ showEditDiff(p, str('cari'), str('ganti'));
46
245
  if (!(await allow(otomatis, `Edit file ${p}?`)))
47
246
  return { output: 'Ditolak oleh pengguna.' };
48
- return { output: editFile(p, str('cari'), str('ganti')) };
247
+ const editOut = editFile(p, str('cari'), str('ganti'));
248
+ return { output: editOut, modifiedPath: editOut.startsWith('OK:') ? p : undefined };
249
+ }
250
+ case 'hapus_file': {
251
+ const p = str('path');
252
+ console.log(` ${cyan('hapus')} ${p}`);
253
+ if (!(await allow(otomatis, `Hapus file ${p}?`)))
254
+ return { output: 'Ditolak oleh pengguna.' };
255
+ const hapusOut = hapusFile(p);
256
+ return { output: hapusOut, modifiedPath: hapusOut.startsWith('OK:') ? p : undefined };
257
+ }
258
+ case 'pindah_file': {
259
+ const dari = str('dari');
260
+ const ke = str('ke');
261
+ console.log(` ${cyan('pindah')} ${dari} → ${ke}`);
262
+ if (!(await allow(otomatis, `Pindah ${dari} → ${ke}?`)))
263
+ return { output: 'Ditolak oleh pengguna.' };
264
+ const pindahOut = pindahFile(dari, ke);
265
+ return { output: pindahOut, modifiedPath: pindahOut.startsWith('OK:') ? ke : undefined };
49
266
  }
50
267
  case 'jalankan': {
51
268
  const cmd = str('perintah');
@@ -53,7 +270,9 @@ async function runTool(call, otomatis) {
53
270
  if (!(await allow(otomatis, 'Jalankan perintah ini?'))) {
54
271
  return { output: 'Ditolak oleh pengguna.' };
55
272
  }
56
- return { output: jalankan(cmd) };
273
+ const cmdOut = await jalankan(cmd);
274
+ showCommandOutput(cmdOut);
275
+ return { output: cmdOut };
57
276
  }
58
277
  case 'buat_gambar': {
59
278
  const p = str('path');
@@ -61,11 +280,41 @@ async function runTool(call, otomatis) {
61
280
  if (!(await allow(otomatis, `Buat gambar ${p}? (berbayar)`))) {
62
281
  return { output: 'Ditolak oleh pengguna.' };
63
282
  }
64
- const model = await resolveMediaModel(undefined, 'image');
65
- const img = await generateImage(model, str('prompt'));
283
+ const imgModel = await resolveMediaModel(undefined, 'image');
284
+ const img = await generateImage(imgModel, str('prompt'));
66
285
  writeFileSync(p, Buffer.from(img.b64, 'base64'));
67
286
  return { output: `OK: ${p} dibuat (${rupiah(img.cost)}).` };
68
287
  }
288
+ case 'lihat_gambar': {
289
+ const p = str('path');
290
+ const pertanyaan = str('pertanyaan') || 'Jelaskan gambar ini.';
291
+ console.log(dim(` lihat ${p} — "${pertanyaan.slice(0, 60)}"`));
292
+ const { abs } = safePath(p);
293
+ if (!existsSync(abs))
294
+ return { output: `Error: file tidak ada: ${p}` };
295
+ const mime = IMAGE_MIME[extname(p).toLowerCase()] ?? 'image/png';
296
+ const dataUrl = `data:${mime};base64,${readFileSync(abs).toString('base64')}`;
297
+ const stop = thinking();
298
+ let answer = '';
299
+ let printed = false;
300
+ try {
301
+ for await (const piece of streamVision(model, dataUrl, pertanyaan)) {
302
+ if (!printed) {
303
+ stop();
304
+ printed = true;
305
+ }
306
+ process.stdout.write(piece);
307
+ answer += piece;
308
+ }
309
+ }
310
+ finally {
311
+ if (!printed)
312
+ stop();
313
+ }
314
+ if (answer)
315
+ process.stdout.write('\n');
316
+ return { output: answer || '(tidak ada respons dari model)' };
317
+ }
69
318
  case 'selesai':
70
319
  return { output: str('ringkasan') || 'selesai', done: true };
71
320
  default:
@@ -84,12 +333,26 @@ export function newSession(model, mode, otomatis) {
84
333
  otomatis,
85
334
  messages: [{ role: 'system', content: systemPrompt(mode) }],
86
335
  teamModels: {},
336
+ totalTokens: 0,
87
337
  };
88
338
  }
89
339
  /** Reset the conversation, keeping the current mode/model/otomatis. */
90
340
  export function resetSession(s) {
91
341
  s.messages = [{ role: 'system', content: systemPrompt(s.mode) }];
92
342
  }
343
+ /**
344
+ * Remove the last user turn and everything that followed it (the agent's response + tool calls).
345
+ * Returns true if something was removed, false if there were no user turns to undo.
346
+ */
347
+ export function undoLastTurn(s) {
348
+ for (let i = s.messages.length - 1; i > 0; i--) {
349
+ if (s.messages[i]?.role === 'user') {
350
+ s.messages = s.messages.slice(0, i);
351
+ return true;
352
+ }
353
+ }
354
+ return false;
355
+ }
93
356
  /**
94
357
  * Process one user turn: run the tool-use loop, appending to the session's history. Returns the
95
358
  * agent's final text (last answer or the `selesai` summary) — useful for chaining agents in a team.
@@ -98,13 +361,26 @@ export async function runTurn(s, userText) {
98
361
  s.messages.push({ role: 'user', content: userText });
99
362
  const tools = toolsForMode(s.mode);
100
363
  let lastText = '';
101
- for (let step = 0; step < MAX_STEPS; step++) {
364
+ let totalIn = 0;
365
+ let totalOut = 0;
366
+ const showUsage = () => {
367
+ if (totalIn + totalOut > 0) {
368
+ console.log(dim(` ↳ ${idn(totalIn + totalOut)} token (${idn(totalIn)} in · ${idn(totalOut)} out)`));
369
+ }
370
+ };
371
+ // Sliding window of recent tool-call signatures (name + serialised args) across all steps.
372
+ // Used to detect the model repeating the same call in a stuck loop (e.g. daftar . × 20).
373
+ const recentSigs = [];
374
+ const LOOP_THRESHOLD = 3;
375
+ const stepLimit = s.maxSteps ?? MAX_STEPS;
376
+ for (let step = 0; step < stepLimit; step++) {
102
377
  const stop = thinking();
103
378
  let streamed = false;
104
379
  let reply;
105
380
  try {
106
381
  // Stream the reply live: stop the spinner on the first token and print as it arrives.
107
- reply = await chatComplete(s.model, s.messages, tools, (delta) => {
382
+ // trimContext caps the payload long sessions with many baca_file results don't balloon.
383
+ reply = await chatComplete(s.model, trimContext(s.messages), tools, (delta) => {
108
384
  if (!streamed) {
109
385
  stop();
110
386
  streamed = true;
@@ -118,6 +394,10 @@ export async function runTurn(s, userText) {
118
394
  }
119
395
  if (streamed)
120
396
  process.stdout.write('\n');
397
+ if (reply.usage) {
398
+ totalIn += reply.usage.promptTokens;
399
+ totalOut += reply.usage.completionTokens;
400
+ }
121
401
  s.messages.push({ role: 'assistant', content: reply.content, tool_calls: reply.tool_calls });
122
402
  if (reply.content)
123
403
  lastText = reply.content;
@@ -126,18 +406,60 @@ export async function runTurn(s, userText) {
126
406
  if (step === 0) {
127
407
  console.log(dim('(Model tidak memakai tool — pilih model ber-🔧 untuk agen, mis. /model deepseek/deepseek-v4-flash.)'));
128
408
  }
129
- return lastText; // final text answer
409
+ showUsage();
410
+ s.totalTokens += totalIn + totalOut;
411
+ return lastText;
412
+ }
413
+ // --- Loop detection: push new signatures, then check if last N are identical ---
414
+ for (const call of calls) {
415
+ recentSigs.push(`${call.function.name}:${call.function.arguments}`);
416
+ }
417
+ if (recentSigs.length > LOOP_THRESHOLD * 3) {
418
+ recentSigs.splice(0, recentSigs.length - LOOP_THRESHOLD * 3);
419
+ }
420
+ if (recentSigs.length >= LOOP_THRESHOLD) {
421
+ const tail = recentSigs.slice(-LOOP_THRESHOLD);
422
+ if (tail.every((sig) => sig === tail[0])) {
423
+ const loopName = calls[0]?.function.name ?? 'unknown';
424
+ console.log(red(`\n⚠ Loop terdeteksi: "${loopName}" dipanggil ${LOOP_THRESHOLD}× berturut-turut dengan argumen sama.`));
425
+ console.log(yellow('Berikan instruksi baru, atau ketik /bersih untuk mulai ulang.'));
426
+ // Push error tool results for all calls in this step so history stays balanced.
427
+ const loopMsg = `⚠ LOOP TERDETEKSI: "${loopName}" sudah dipanggil ${LOOP_THRESHOLD}× dengan argumen yang sama. BERHENTI memanggil tool ini. Gunakan tool "selesai" dengan penjelasan bahwa tugas tidak dapat diselesaikan, atau tunggu instruksi baru dari pengguna.`;
428
+ for (const call of calls) {
429
+ s.messages.push({ role: 'tool', content: loopMsg, tool_call_id: call.id });
430
+ }
431
+ saveCheckpoint(s);
432
+ showUsage();
433
+ s.totalTokens += totalIn + totalOut;
434
+ return lastText;
435
+ }
130
436
  }
437
+ const stepModified = new Set();
131
438
  for (const call of calls) {
132
- const result = await runTool(call, s.otomatis);
439
+ const result = await runTool(call, s.otomatis, s.model);
133
440
  s.messages.push({ role: 'tool', content: result.output, tool_call_id: call.id });
441
+ if (result.modifiedPath)
442
+ stepModified.add(result.modifiedPath);
134
443
  if (result.done) {
444
+ if (stepModified.size > 0)
445
+ console.log(dim(` ✎ ${[...stepModified].join(' · ')}`));
135
446
  console.log(green(`\n✓ Selesai: ${result.output}`));
447
+ showUsage();
448
+ s.totalTokens += totalIn + totalOut;
449
+ clearCheckpoint();
136
450
  return result.output;
137
451
  }
138
452
  }
453
+ if (stepModified.size > 0)
454
+ console.log(dim(` ✎ ${[...stepModified].join(' · ')}`));
455
+ // Save after ALL tool results for this step are appended — a partial save (mid-step) would
456
+ // leave the history with an assistant message whose tool_calls have no matching tool results,
457
+ // which providers reject on the next call.
458
+ saveCheckpoint(s);
139
459
  }
140
- console.log(yellow(`\nBerhenti setelah ${MAX_STEPS} langkah. Lanjutkan dengan perintah berikutnya.`));
460
+ console.log(yellow(`\nBerhenti setelah ${stepLimit} langkah. Lanjutkan dengan perintah berikutnya.`));
461
+ showUsage();
462
+ s.totalTokens += totalIn + totalOut;
141
463
  return lastText;
142
464
  }
143
465
  /** One-shot: run a single task in a mode (used by the `rencana`/`edit`/`buat` subcommands). */
@@ -99,6 +99,51 @@ const TOOLS = {
99
99
  },
100
100
  },
101
101
  },
102
+ lihat_gambar: {
103
+ type: 'function',
104
+ function: {
105
+ name: 'lihat_gambar',
106
+ description: 'Lihat dan analisis gambar dari file lokal (vision). Berguna untuk membaca screenshot UI, diagram, atau aset gambar.',
107
+ parameters: {
108
+ type: 'object',
109
+ properties: {
110
+ path: { type: 'string', description: 'Path file gambar (.png, .jpg, .webp, .gif)' },
111
+ pertanyaan: {
112
+ type: 'string',
113
+ description: 'Pertanyaan atau instruksi tentang gambar ini',
114
+ },
115
+ },
116
+ required: ['path', 'pertanyaan'],
117
+ },
118
+ },
119
+ },
120
+ hapus_file: {
121
+ type: 'function',
122
+ function: {
123
+ name: 'hapus_file',
124
+ description: 'Hapus sebuah file. Lebih aman dari menjalankan del/rm.',
125
+ parameters: {
126
+ type: 'object',
127
+ properties: { path: { type: 'string', description: 'Path file yang akan dihapus' } },
128
+ required: ['path'],
129
+ },
130
+ },
131
+ },
132
+ pindah_file: {
133
+ type: 'function',
134
+ function: {
135
+ name: 'pindah_file',
136
+ description: 'Pindahkan atau ganti nama file/folder.',
137
+ parameters: {
138
+ type: 'object',
139
+ properties: {
140
+ dari: { type: 'string', description: 'Path asal' },
141
+ ke: { type: 'string', description: 'Path tujuan' },
142
+ },
143
+ required: ['dari', 'ke'],
144
+ },
145
+ },
146
+ },
102
147
  selesai: {
103
148
  type: 'function',
104
149
  function: {
@@ -113,11 +158,11 @@ const TOOLS = {
113
158
  },
114
159
  };
115
160
  const READ_TOOLS = ['baca_file', 'daftar_file', 'cari'];
116
- const WRITE_TOOLS = ['tulis_file', 'edit_file'];
161
+ const WRITE_TOOLS = ['tulis_file', 'edit_file', 'hapus_file', 'pindah_file'];
117
162
  const MODE_TOOLS = {
118
- rencana: [...READ_TOOLS, 'selesai'],
119
- edit: [...READ_TOOLS, ...WRITE_TOOLS, 'buat_gambar', 'selesai'],
120
- buat: [...READ_TOOLS, ...WRITE_TOOLS, 'jalankan', 'buat_gambar', 'selesai'],
163
+ rencana: [...READ_TOOLS, 'lihat_gambar', 'selesai'],
164
+ edit: [...READ_TOOLS, ...WRITE_TOOLS, 'lihat_gambar', 'buat_gambar', 'selesai'],
165
+ buat: [...READ_TOOLS, ...WRITE_TOOLS, 'jalankan', 'lihat_gambar', 'buat_gambar', 'selesai'],
121
166
  };
122
167
  /** The tool specs available in a given mode. */
123
168
  export function toolsForMode(mode) {
@@ -135,13 +180,22 @@ const MODE_BRIEF = {
135
180
  };
136
181
  /** System prompt (Indonesian) that frames the agent's role, mode, and workflow. */
137
182
  export function systemPrompt(mode) {
183
+ const platform = process.platform;
184
+ const osName = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'macOS' : 'Linux';
185
+ const shellNote = platform === 'win32'
186
+ ? 'Shell: cmd.exe — gunakan sintaks Windows: mkdir folder (bukan mkdir -p), del file (bukan rm), move src dst (bukan mv), copy src dst (bukan cp). JANGAN pakai perintah bash/unix.'
187
+ : 'Shell: bash';
138
188
  return `Kamu adalah asisten coding Kiosapi yang bekerja di terminal developer Indonesia.
139
189
  ${MODE_BRIEF[mode]}
140
190
 
141
191
  Direktori kerja: ${process.cwd()}
192
+ OS: ${osName} · ${shellNote}
142
193
  Aturan:
143
194
  - Bekerja langkah demi langkah: pakai tool untuk membaca sebelum mengubah.
144
195
  - Path selalu relatif ke direktori kerja; akses ke luar ditolak.
196
+ - Gunakan hapus_file/pindah_file untuk menghapus/memindahkan file (lebih aman dari jalankan del/rm).
145
197
  - Buat perubahan kecil dan jelas. Setelah tugas beres, panggil tool "selesai" dengan ringkasan.
198
+ - JANGAN memanggil tool yang sama dengan argumen yang sama lebih dari 1× berturut-turut. Jika sudah mendapat hasil daftar_file atau baca_file, langsung lanjutkan — JANGAN ulangi panggilan yang sama.
199
+ - Jika tidak tahu harus berbuat apa selanjutnya, gunakan tool "selesai" dan jelaskan apa yang sudah ditemukan.
146
200
  - Jawab dan jelaskan dalam Bahasa Indonesia.`;
147
201
  }
@@ -1,4 +1,5 @@
1
- import { bold, dim, green } from '../ui.js';
1
+ import { modelSupportsTools } from '../api.js';
2
+ import { bold, dim, green, yellow } from '../ui.js';
2
3
  import { newSession, runTurn } from './run.js';
3
4
  const ROLES = {
4
5
  perencana: {
@@ -34,3 +35,39 @@ export async function runTeam(task, opts) {
34
35
  await runRole('peninjau', `Tinjau hasil implementasi untuk tugas: ${task}`, opts);
35
36
  console.log(green('\n✓ Tim selesai.'));
36
37
  }
38
+ /**
39
+ * Run a custom team defined by a TeamConfig. Each role runs sequentially; the output of one role
40
+ * is passed as context to the next.
41
+ */
42
+ export async function runCustomTeam(task, opts) {
43
+ const { config, otomatis, modelOverrides = {} } = opts;
44
+ const alur = config.alur.filter((r) => r in config.peran);
45
+ console.log(bold(`👥 Tim "${config.nama}": ${alur.join(' → ')}`));
46
+ // Warn about models that may lack tool support (best-effort, silent on network error)
47
+ const uniqueModels = [
48
+ ...new Set(alur.map((r) => modelOverrides[r] ?? config.peran[r]?.model).filter(Boolean)),
49
+ ];
50
+ await Promise.all(uniqueModels.map(async (m) => {
51
+ if (!(await modelSupportsTools(m).catch(() => true))) {
52
+ console.log(yellow(`⚠ Model "${m}" mungkin tidak mendukung tool calling — agen mungkin tidak berjalan.`));
53
+ }
54
+ }));
55
+ let context = task;
56
+ for (let i = 0; i < alur.length; i++) {
57
+ const roleName = alur[i] ?? '';
58
+ const roleCfg = config.peran[roleName];
59
+ if (!roleCfg) {
60
+ console.log(yellow(`⚠ Peran "${roleName}" tidak ada dalam config — dilewati.`));
61
+ continue;
62
+ }
63
+ const model = modelOverrides[roleName] ?? roleCfg.model;
64
+ const mode = roleCfg.mode ?? 'buat';
65
+ console.log(`\n${bold(`${String(i + 1).padStart(2)}. ${roleName}`)} ${dim(`(${model}, mode: ${mode})`)}`);
66
+ const s = newSession(model, mode, otomatis);
67
+ if (roleCfg.brief)
68
+ s.messages.push({ role: 'system', content: roleCfg.brief });
69
+ const result = await runTurn(s, context);
70
+ context = `Tugas asal: ${task}\n\nOutput dari ${roleName}:\n${result || '(tidak ada output eksplisit)'}`;
71
+ }
72
+ console.log(green(`\n✓ Tim "${config.nama}" selesai.`));
73
+ }