kiosapi 0.1.6 → 0.1.8

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,22 @@ 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
+ const stepLimit = s.maxSteps ?? MAX_STEPS;
372
+ for (let step = 0; step < stepLimit; step++) {
102
373
  const stop = thinking();
103
374
  let streamed = false;
104
375
  let reply;
105
376
  try {
106
377
  // 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) => {
378
+ // trimContext caps the payload long sessions with many baca_file results don't balloon.
379
+ reply = await chatComplete(s.model, trimContext(s.messages), tools, (delta) => {
108
380
  if (!streamed) {
109
381
  stop();
110
382
  streamed = true;
@@ -118,6 +390,10 @@ export async function runTurn(s, userText) {
118
390
  }
119
391
  if (streamed)
120
392
  process.stdout.write('\n');
393
+ if (reply.usage) {
394
+ totalIn += reply.usage.promptTokens;
395
+ totalOut += reply.usage.completionTokens;
396
+ }
121
397
  s.messages.push({ role: 'assistant', content: reply.content, tool_calls: reply.tool_calls });
122
398
  if (reply.content)
123
399
  lastText = reply.content;
@@ -126,18 +402,36 @@ export async function runTurn(s, userText) {
126
402
  if (step === 0) {
127
403
  console.log(dim('(Model tidak memakai tool — pilih model ber-🔧 untuk agen, mis. /model deepseek/deepseek-v4-flash.)'));
128
404
  }
129
- return lastText; // final text answer
405
+ showUsage();
406
+ s.totalTokens += totalIn + totalOut;
407
+ return lastText;
130
408
  }
409
+ const stepModified = new Set();
131
410
  for (const call of calls) {
132
- const result = await runTool(call, s.otomatis);
411
+ const result = await runTool(call, s.otomatis, s.model);
133
412
  s.messages.push({ role: 'tool', content: result.output, tool_call_id: call.id });
413
+ if (result.modifiedPath)
414
+ stepModified.add(result.modifiedPath);
134
415
  if (result.done) {
416
+ if (stepModified.size > 0)
417
+ console.log(dim(` ✎ ${[...stepModified].join(' · ')}`));
135
418
  console.log(green(`\n✓ Selesai: ${result.output}`));
419
+ showUsage();
420
+ s.totalTokens += totalIn + totalOut;
421
+ clearCheckpoint();
136
422
  return result.output;
137
423
  }
138
424
  }
425
+ if (stepModified.size > 0)
426
+ console.log(dim(` ✎ ${[...stepModified].join(' · ')}`));
427
+ // Save after ALL tool results for this step are appended — a partial save (mid-step) would
428
+ // leave the history with an assistant message whose tool_calls have no matching tool results,
429
+ // which providers reject on the next call.
430
+ saveCheckpoint(s);
139
431
  }
140
- console.log(yellow(`\nBerhenti setelah ${MAX_STEPS} langkah. Lanjutkan dengan perintah berikutnya.`));
432
+ console.log(yellow(`\nBerhenti setelah ${stepLimit} langkah. Lanjutkan dengan perintah berikutnya.`));
433
+ showUsage();
434
+ s.totalTokens += totalIn + totalOut;
141
435
  return lastText;
142
436
  }
143
437
  /** 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,20 @@ 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.
146
198
  - Jawab dan jelaskan dalam Bahasa Indonesia.`;
147
199
  }
@@ -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
+ }