kiosapi 0.1.11 → 0.1.16
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 +85 -12
- package/dist/agent/schemas.js +13 -4
- package/dist/agent/tools.js +33 -10
- package/package.json +1 -1
package/dist/agent/run.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { extname } from 'node:path';
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
3
|
import { chatComplete, generateImage, resolveMediaModel, streamVision, } from '../api.js';
|
|
4
4
|
import { CHECKPOINT_PATH } from '../config.js';
|
|
5
5
|
import { cyan, dim, green, idn, prompt, red, rupiah, thinking, yellow } from '../ui.js';
|
|
@@ -20,6 +20,8 @@ const MAX_CONTEXT_MESSAGES = 40;
|
|
|
20
20
|
*
|
|
21
21
|
* The kept slice always:
|
|
22
22
|
* - starts with the original system prompt
|
|
23
|
+
* - removes any assistant messages with null content and no tool_calls (invalid for providers;
|
|
24
|
+
* they appear when a stream ends prematurely or the model produces reasoning-only output)
|
|
23
25
|
* - contains at most MAX_CONTEXT_MESSAGES non-system messages
|
|
24
26
|
* - begins on a `user` turn boundary (never mid tool-call sequence, which providers reject)
|
|
25
27
|
* - prepends a system note so the model knows early context was dropped
|
|
@@ -31,19 +33,29 @@ function trimContext(messages) {
|
|
|
31
33
|
if (messages.length === 0)
|
|
32
34
|
return messages;
|
|
33
35
|
const [system, ...rest] = messages;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
// Sanitize: assistant messages with no content AND no tool_calls are invalid for providers
|
|
37
|
+
// (they arise from truncated streams or reasoning-only model steps). Remove them so they
|
|
38
|
+
// cannot corrupt subsequent API calls.
|
|
39
|
+
const clean = rest.filter((m) => {
|
|
40
|
+
if (m.role !== 'assistant')
|
|
41
|
+
return true;
|
|
42
|
+
return m.content !== null || (m.tool_calls?.length ?? 0) > 0;
|
|
43
|
+
});
|
|
44
|
+
if (clean.length <= MAX_CONTEXT_MESSAGES) {
|
|
45
|
+
// Return original if nothing changed (avoids allocation on the common path)
|
|
46
|
+
return clean.length === rest.length ? messages : [system, ...clean];
|
|
47
|
+
}
|
|
36
48
|
// Take the tail we want to keep, then advance past any leading tool/assistant messages so
|
|
37
49
|
// the slice always starts on a complete user turn. Orphaned tool results (whose paired
|
|
38
50
|
// assistant message was trimmed) cause provider errors on the next API call.
|
|
39
|
-
let tail =
|
|
51
|
+
let tail = clean.slice(-MAX_CONTEXT_MESSAGES);
|
|
40
52
|
let skip = 0;
|
|
41
53
|
while (skip < tail.length && tail[skip]?.role !== 'user')
|
|
42
54
|
skip++;
|
|
43
55
|
tail = tail.slice(skip);
|
|
44
|
-
const dropped =
|
|
56
|
+
const dropped = clean.length - tail.length;
|
|
45
57
|
if (dropped <= 0)
|
|
46
|
-
return
|
|
58
|
+
return [system, ...clean];
|
|
47
59
|
const note = {
|
|
48
60
|
role: 'system',
|
|
49
61
|
content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks untuk menghemat token. Lanjutkan dari konteks terkini di bawah.]`,
|
|
@@ -223,9 +235,12 @@ async function runTool(call, otomatis, model) {
|
|
|
223
235
|
case 'baca_file':
|
|
224
236
|
console.log(dim(` baca ${str('path')}`));
|
|
225
237
|
return { output: bacaFile(str('path')) };
|
|
226
|
-
case 'daftar_file':
|
|
227
|
-
|
|
228
|
-
|
|
238
|
+
case 'daftar_file': {
|
|
239
|
+
const ked = typeof args['kedalaman'] === 'number' ? args['kedalaman'] : undefined;
|
|
240
|
+
const dPath = str('path') || '.';
|
|
241
|
+
console.log(dim(` daftar ${dPath}${ked != null ? ` (kedalaman ${ked})` : ''}`));
|
|
242
|
+
return { output: daftarFile(dPath, ked) };
|
|
243
|
+
}
|
|
229
244
|
case 'cari':
|
|
230
245
|
console.log(dim(` cari "${str('pola')}"`));
|
|
231
246
|
return { output: cari(str('pola'), str('ext') || undefined) };
|
|
@@ -358,6 +373,32 @@ export function undoLastTurn(s) {
|
|
|
358
373
|
* agent's final text (last answer or the `selesai` summary) — useful for chaining agents in a team.
|
|
359
374
|
*/
|
|
360
375
|
export async function runTurn(s, userText) {
|
|
376
|
+
// On the very first turn of a fresh session, auto-inject project metadata so the model
|
|
377
|
+
// starts oriented without needing to call daftar_file(".") for basic orientation.
|
|
378
|
+
// Injected: root directory tree + key manifest files (package.json, README, etc.).
|
|
379
|
+
if (s.messages.filter((m) => m.role === 'user').length === 0) {
|
|
380
|
+
const snippets = [];
|
|
381
|
+
try {
|
|
382
|
+
snippets.push(`<root-directory>\n${daftarFile('.')}\n</root-directory>`);
|
|
383
|
+
}
|
|
384
|
+
catch { /* ignore */ }
|
|
385
|
+
for (const f of ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'README.md']) {
|
|
386
|
+
const abs = join(process.cwd(), f);
|
|
387
|
+
if (existsSync(abs)) {
|
|
388
|
+
try {
|
|
389
|
+
const content = readFileSync(abs, 'utf8');
|
|
390
|
+
snippets.push(`<file path="${f}">\n${content.slice(0, 4000)}\n</file>`);
|
|
391
|
+
}
|
|
392
|
+
catch { /* unreadable — skip */ }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (snippets.length > 0) {
|
|
396
|
+
s.messages.push({
|
|
397
|
+
role: 'system',
|
|
398
|
+
content: `## Konteks Proyek (auto-injected — jangan panggil daftar_file(".") lagi)\n${snippets.join('\n\n')}`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
361
402
|
s.messages.push({ role: 'user', content: userText });
|
|
362
403
|
const tools = toolsForMode(s.mode);
|
|
363
404
|
let lastText = '';
|
|
@@ -375,6 +416,11 @@ export async function runTurn(s, userText) {
|
|
|
375
416
|
const lastSigs = []; // last 3 sigs for consecutive detection
|
|
376
417
|
const COUNT_LIMIT = 4; // same tool+args called 4× total → loop
|
|
377
418
|
const CONSEC_LIMIT = 3; // same sig 3× in a row → loop
|
|
419
|
+
// Read-only tool cache: on 2nd+ identical call, return the previous result with a warning
|
|
420
|
+
// instead of re-running. This gives the model early feedback so it can self-correct before
|
|
421
|
+
// COUNT_LIMIT is reached, preventing the common "list root, list root, list root" pattern.
|
|
422
|
+
const READ_ONLY_TOOLS = new Set(['baca_file', 'daftar_file', 'cari', 'lihat_gambar']);
|
|
423
|
+
const toolCache = new Map(); // sig → first output
|
|
378
424
|
const stepLimit = s.maxSteps ?? MAX_STEPS;
|
|
379
425
|
for (let step = 0; step < stepLimit; step++) {
|
|
380
426
|
const stop = thinking();
|
|
@@ -401,13 +447,20 @@ export async function runTurn(s, userText) {
|
|
|
401
447
|
totalIn += reply.usage.promptTokens;
|
|
402
448
|
totalOut += reply.usage.completionTokens;
|
|
403
449
|
}
|
|
404
|
-
|
|
450
|
+
const calls = reply.tool_calls ?? [];
|
|
451
|
+
// Only push the assistant message when it carries meaningful content. An empty response
|
|
452
|
+
// (null content + no tool_calls) can appear from a truncated stream or a reasoning-only
|
|
453
|
+
// step; pushing it corrupts the history and causes providers to reject subsequent calls.
|
|
454
|
+
if (reply.content !== null || calls.length > 0) {
|
|
455
|
+
s.messages.push({ role: 'assistant', content: reply.content, tool_calls: reply.tool_calls });
|
|
456
|
+
}
|
|
405
457
|
if (reply.content)
|
|
406
458
|
lastText = reply.content;
|
|
407
|
-
const calls = reply.tool_calls ?? [];
|
|
408
459
|
if (calls.length === 0) {
|
|
409
460
|
if (step === 0) {
|
|
410
|
-
console.log(dim(
|
|
461
|
+
console.log(dim(reply.content
|
|
462
|
+
? '(Model tidak memakai tool — pilih model ber-🔧 untuk agen, mis. /model deepseek/deepseek-v4-flash.)'
|
|
463
|
+
: '(Model tidak merespons — coba ulangi atau ganti model.)'));
|
|
411
464
|
}
|
|
412
465
|
showUsage();
|
|
413
466
|
s.totalTokens += totalIn + totalOut;
|
|
@@ -447,10 +500,30 @@ export async function runTurn(s, userText) {
|
|
|
447
500
|
}
|
|
448
501
|
const stepModified = new Set();
|
|
449
502
|
for (const call of calls) {
|
|
503
|
+
const sig = `${call.function.name}:${call.function.arguments}`;
|
|
504
|
+
const count = callCounts.get(sig) ?? 1;
|
|
505
|
+
// For read-only tools called more than once with the same args: return the cached result
|
|
506
|
+
// with an escalating warning instead of re-running. The model gets immediate feedback so
|
|
507
|
+
// it can self-correct (pick a subfolder, go deeper) rather than looping to COUNT_LIMIT.
|
|
508
|
+
if (count > 1 && READ_ONLY_TOOLS.has(call.function.name) && toolCache.has(sig)) {
|
|
509
|
+
const toolName = call.function.name;
|
|
510
|
+
const cachedOut = toolCache.get(sig) ?? '';
|
|
511
|
+
const warn = count >= COUNT_LIMIT - 1
|
|
512
|
+
? `⚠ STOP: "${toolName}" sudah dipanggil ${count}× dengan argumen yang sama — ` +
|
|
513
|
+
`hasilnya tidak berubah. WAJIB gunakan tool "selesai" atau eksplorasi path BERBEDA.`
|
|
514
|
+
: `[Cache — identik dengan panggilan sebelumnya]\n${cachedOut}\n\n` +
|
|
515
|
+
`⚠ Kamu sudah memanggil "${toolName}" dengan argumen yang sama ${count}×. ` +
|
|
516
|
+
`Jangan ulangi — masuk ke subfolder spesifik atau gunakan "selesai".`;
|
|
517
|
+
console.log(dim(` ↩ ${toolName} (cache ke-${count})`));
|
|
518
|
+
s.messages.push({ role: 'tool', content: warn, tool_call_id: call.id });
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
450
521
|
const result = await runTool(call, s.otomatis, s.model);
|
|
451
522
|
s.messages.push({ role: 'tool', content: result.output, tool_call_id: call.id });
|
|
452
523
|
if (result.modifiedPath)
|
|
453
524
|
stepModified.add(result.modifiedPath);
|
|
525
|
+
if (READ_ONLY_TOOLS.has(call.function.name))
|
|
526
|
+
toolCache.set(sig, result.output);
|
|
454
527
|
if (result.done) {
|
|
455
528
|
if (stepModified.size > 0)
|
|
456
529
|
console.log(dim(` ✎ ${[...stepModified].join(' · ')}`));
|
package/dist/agent/schemas.js
CHANGED
|
@@ -16,10 +16,18 @@ const TOOLS = {
|
|
|
16
16
|
type: 'function',
|
|
17
17
|
function: {
|
|
18
18
|
name: 'daftar_file',
|
|
19
|
-
description: 'Daftar isi sebuah folder.',
|
|
19
|
+
description: 'Daftar isi sebuah folder sebagai tree. Default: 3 level untuk ".", 1 level untuk subfolder. Gunakan kedalaman=2-4 untuk subfolder yang dalam.',
|
|
20
20
|
parameters: {
|
|
21
21
|
type: 'object',
|
|
22
|
-
properties: {
|
|
22
|
+
properties: {
|
|
23
|
+
path: { type: 'string', description: 'Path folder (default ".")' },
|
|
24
|
+
kedalaman: {
|
|
25
|
+
type: 'integer',
|
|
26
|
+
description: 'Kedalaman tree (1–5). Default: 3 untuk ".", 1 untuk subfolder.',
|
|
27
|
+
minimum: 1,
|
|
28
|
+
maximum: 5,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
23
31
|
},
|
|
24
32
|
},
|
|
25
33
|
},
|
|
@@ -192,10 +200,11 @@ Direktori kerja: ${process.cwd()}
|
|
|
192
200
|
OS: ${osName} · ${shellNote}
|
|
193
201
|
Aturan:
|
|
194
202
|
- Bekerja langkah demi langkah: pakai tool untuk membaca sebelum mengubah.
|
|
203
|
+
- Strategi eksplorasi: daftar_file(".") sudah tersedia di konteks awal — identifikasi file yang relevan LANGSUNG, lalu baca dengan baca_file. Jangan ulangi daftar_file pada path yang sama.
|
|
204
|
+
- JANGAN memanggil tool APAPUN dengan argumen identik lebih dari 1× dalam satu sesi. Jika tool mengembalikan peringatan cache "⚠", langsung ganti ke path atau argumen BERBEDA.
|
|
195
205
|
- Path selalu relatif ke direktori kerja; akses ke luar ditolak.
|
|
196
206
|
- Gunakan hapus_file/pindah_file untuk menghapus/memindahkan file (lebih aman dari jalankan del/rm).
|
|
197
207
|
- Buat perubahan kecil dan jelas. Setelah tugas beres, panggil tool "selesai" dengan ringkasan.
|
|
198
|
-
-
|
|
199
|
-
- Jika tidak tahu harus berbuat apa selanjutnya, gunakan tool "selesai" dan jelaskan apa yang sudah ditemukan.
|
|
208
|
+
- Jika tidak tahu harus berbuat apa: gunakan tool "selesai" dan jelaskan apa yang sudah ditemukan.
|
|
200
209
|
- Jawab dan jelaskan dalam Bahasa Indonesia.`;
|
|
201
210
|
}
|
package/dist/agent/tools.js
CHANGED
|
@@ -52,20 +52,43 @@ export function bacaFile(path) {
|
|
|
52
52
|
? `${text.slice(0, MAX_READ)}\n…[dipotong, ${text.length} char total]`
|
|
53
53
|
: text;
|
|
54
54
|
}
|
|
55
|
-
/** daftar_file — list a directory (ignored entries hidden).
|
|
56
|
-
|
|
55
|
+
/** daftar_file — list a directory (ignored entries hidden).
|
|
56
|
+
* kedalaman controls tree depth (1–5). Defaults: 3 for root ".", 1 for subdirs.
|
|
57
|
+
* Output is capped at 300 lines to keep API payloads reasonable. */
|
|
58
|
+
export function daftarFile(path, kedalaman) {
|
|
57
59
|
const { abs, rel } = safePath(path || '.');
|
|
58
60
|
if (!existsSync(abs))
|
|
59
61
|
return `Error: folder tidak ada: ${path}`;
|
|
60
62
|
const patterns = ignorePatterns();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
// Default depth: 3 for root (wide orientation), 1 for any subdir (focused)
|
|
64
|
+
const depth = kedalaman != null ? Math.min(Math.max(1, kedalaman), 5) : rel === '.' ? 3 : 1;
|
|
65
|
+
const listDir = (dirAbs, dirRel, d, indent, lines) => {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dirAbs)
|
|
69
|
+
.filter((name) => !isProtected(dirRel === '.' ? name : `${dirRel}/${name}`, patterns))
|
|
70
|
+
.sort()
|
|
71
|
+
.map((name) => ({ name, isDir: statSync(join(dirAbs, name)).isDirectory() }));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return; // unreadable dir — skip silently
|
|
75
|
+
}
|
|
76
|
+
for (const { name, isDir } of entries) {
|
|
77
|
+
if (lines.length >= 300) {
|
|
78
|
+
lines.push(' … (dipotong — panggil lagi dengan subfolder spesifik)');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
lines.push(`${indent}${name}${isDir ? '/' : ''}`);
|
|
82
|
+
if (isDir && d > 1) {
|
|
83
|
+
const subAbs = join(dirAbs, name);
|
|
84
|
+
const subRel = dirRel === '.' ? name : `${dirRel}/${name}`;
|
|
85
|
+
listDir(subAbs, subRel, d - 1, `${indent} `, lines);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const lines = [];
|
|
90
|
+
listDir(abs, rel, depth, '', lines);
|
|
91
|
+
return lines.length > 0 ? lines.join('\n') : '(kosong)';
|
|
69
92
|
}
|
|
70
93
|
/** cari — grep file contents under the working dir (case-insensitive). */
|
|
71
94
|
export function cari(pola, ext) {
|