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 +310 -16
- package/dist/agent/schemas.js +56 -4
- package/dist/agent/team.js +38 -1
- package/dist/agent/tools.js +186 -15
- package/dist/api.js +54 -18
- package/dist/commands.js +256 -11
- package/dist/config.js +61 -3
- package/dist/help.js +46 -28
- package/dist/index.js +18 -2
- package/dist/session.js +393 -55
- package/dist/ui.js +5 -0
- package/package.json +2 -5
package/dist/agent/run.js
CHANGED
|
@@ -1,9 +1,199 @@
|
|
|
1
|
-
import { writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
const img = await generateImage(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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). */
|
package/dist/agent/schemas.js
CHANGED
|
@@ -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
|
}
|
package/dist/agent/team.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
+
}
|