jaitus 0.3.6 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "scripts": {
19
- "test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js",
19
+ "test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -106,6 +106,9 @@ How to work:
106
106
  - Run the project's own tests or build after a change when one exists, and report the real result — including when it fails.
107
107
  - Use run_command for anything the shell does better: git, tests, package managers, linters.
108
108
  - Never invent a file path, a function name, or a test result. If you are unsure, look.
109
+ - Every turn re-sends the whole conversation, so make independent reads, searches and
110
+ listings together in ONE turn rather than one per turn. Read the part of a large file you
111
+ need (offset/limit) rather than all of it.
109
112
  - For current news or other web facts, report only details grounded in web_search or
110
113
  web_fetch output. Include the supplied source URLs; never fill a missing headline or
111
114
  date from memory.
@@ -229,6 +232,69 @@ export function repairToolPairs(messages) {
229
232
  return messages;
230
233
  }
231
234
 
235
+ // ── Keeping the conversation small ────────────────────────────────────────────
236
+ //
237
+ // Every request re-sends the whole conversation, and in real use that re-send — cached,
238
+ // but not free — was ~75% of what a session cost: 109 requests averaged 46k tokens of
239
+ // context each, most of it tool output from many steps ago (a file read at step 3 is
240
+ // still paid for at step 60). This is the documented "clear old tool results" strategy
241
+ // (Anthropic's context editing, clear_tool_uses; Claude Code's micro-compaction): once
242
+ // the conversation passes TRIM_AT, the outputs of all but the KEEP_RECENT latest tool
243
+ // calls are replaced with a line saying they were cleared and can be re-run. It clears
244
+ // at least TRIM_AT_LEAST at a time, because each clearing breaks the prompt cache once
245
+ // — clearing a little on every step would cost more than it saves. Small results (a
246
+ // "file written", an exit code) are kept: they carry meaning and cost nothing.
247
+ // 100k was the documented default for 200k-window models. Here the window is up to 1M, so
248
+ // that ceiling was rarely near: production requests averaged 53k tokens and two thirds of
249
+ // all input tokens came from requests over 60k. At 40k a clearing (one cache break, ~17
250
+ // credits on glm-5.3-flash) pays for itself within about ten later requests.
251
+ export const TRIM_AT = 40_000; // estimated tokens of conversation
252
+ export const KEEP_RECENT = 6; // latest tool results always kept whole
253
+ export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
254
+ const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
255
+ const CLEARED = '[Output cleared';
256
+
257
+ const blockChars = (b) => {
258
+ if (b?.type === 'text') return (b.text || '').length;
259
+ if (b?.type === 'tool_use') return JSON.stringify(b.input || {}).length;
260
+ if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content || '').length;
261
+ return 0;
262
+ };
263
+ export function conversationTokens(messages) {
264
+ let chars = 0;
265
+ for (const m of messages || []) chars += typeof m?.content === 'string' ? m.content.length : (Array.isArray(m?.content) ? m.content.reduce((n, b) => n + blockChars(b), 0) : 0);
266
+ return Math.ceil(chars / 4);
267
+ }
268
+
269
+ /**
270
+ * Clear old tool outputs in place when the conversation is large. Returns how many
271
+ * estimated tokens were freed (0 when nothing was worth clearing).
272
+ */
273
+ export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT, atLeast = TRIM_AT_LEAST } = {}) {
274
+ if (conversationTokens(messages) < at) return 0;
275
+ const names = new Map();
276
+ for (const m of messages) {
277
+ if (m?.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b?.type === 'tool_use') names.set(b.id, b.name);
278
+ }
279
+ const results = [];
280
+ for (const m of messages) {
281
+ if (m?.role !== 'user' || !Array.isArray(m.content)) continue;
282
+ m.content.forEach((b, i) => { if (b?.type === 'tool_result') results.push({ m, i, b }); });
283
+ }
284
+ const candidates = results.slice(0, Math.max(0, results.length - keep)).filter(({ b }) => {
285
+ const text = typeof b.content === 'string' ? b.content : '';
286
+ return text.length >= TRIM_MIN_CHARS && !text.startsWith(CLEARED);
287
+ });
288
+ const freed = Math.ceil(candidates.reduce((n, { b }) => n + blockChars(b), 0) / 4);
289
+ if (freed < atLeast) return 0;
290
+ for (const { m, i, b } of candidates) {
291
+ const chars = blockChars(b);
292
+ const name = names.get(b.tool_use_id) || 'this tool';
293
+ m.content[i] = { ...b, content: `${CLEARED}: ${name} returned ${chars.toLocaleString()} characters here, removed to keep the conversation small. Run it again if you need it.]` };
294
+ }
295
+ return freed;
296
+ }
297
+
232
298
  export const IMAGE_BUDGET = 6_000_000;
233
299
  export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
234
300
  let used = 0;
@@ -273,14 +339,16 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
273
339
  // — no message_stop, no stop reason — was cut, and used to be taken as a complete,
274
340
  // silent answer: the "it stops midway for no reason" bug.
275
341
  let sawStop = false;
276
- const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
342
+ // `credits` is what the gateway counted against the plan for this request — real cost,
343
+ // the one unit the plan, the dashboard and this CLI all show.
344
+ const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, credits: 0 };
277
345
  // Usage can arrive at the START of a stream (Anthropic-format providers) or only at
278
346
  // the END, in message_delta (OpenAI-format providers, translated by the gateway —
279
347
  // their usage exists only once generation finishes). Reading input from message_start
280
348
  // alone recorded every OpenAI-format turn as 0 input tokens.
281
349
  const takeUsage = (u) => {
282
350
  if (!u) return;
283
- for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
351
+ for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'credits']) {
284
352
  if (u[k] != null && Number.isFinite(Number(u[k]))) usage[k] = Number(u[k]);
285
353
  }
286
354
  };
@@ -395,7 +463,7 @@ export async function runAgent({
395
463
  client, messages, model, cwd, permissions, signal, tui = null,
396
464
  extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
397
465
  onText = null, onEvent = null, systemOverride = null, journal = null,
398
- subagents = true, onContext = null,
466
+ subagents = true, onContext = null, trimContext = true,
399
467
  }) {
400
468
  // Loop detection. A call is identified by its tool, its input, and how many files had
401
469
  // been changed when it was made — so edit → test → edit → test is progress (the count
@@ -404,7 +472,7 @@ export async function runAgent({
404
472
  const repeats = new Map();
405
473
  // input = fresh input, cached = prompt-cache reads (billed far cheaper), and
406
474
  // context = the real size of the last prompt sent, as the provider counted it.
407
- const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0 };
475
+ const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0, credits: 0 };
408
476
  // Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
409
477
  // should not change the rules it is being judged by halfway through.
410
478
  // The model is told when it is in plan mode — it used to find out only by having its
@@ -438,6 +506,12 @@ export async function runAgent({
438
506
  let continuing = false;
439
507
 
440
508
  for (let iteration = 0; iteration < maxIterations; iteration++) {
509
+ // Old tool outputs are cleared once the conversation is large (see TRIM_AT) — in
510
+ // place, so the saved session shrinks too and the cache stays warm afterwards.
511
+ if (trimContext) {
512
+ const freed = clearOldToolResults(messages);
513
+ if (freed && tui?.notice) tui.notice(`Cleared old tool output (~${Math.round(freed / 1000)}k tokens) to keep each request small.`, 'info');
514
+ }
441
515
  let streamed = false;
442
516
  const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
443
517
  if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
@@ -532,6 +606,7 @@ export async function runAgent({
532
606
  totals.input += turn.usage.input_tokens || 0;
533
607
  totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
534
608
  totals.output += turn.usage.output_tokens || 0;
609
+ totals.credits += Number(turn.usage.credits) || 0;
535
610
  const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
536
611
  if (promptSize) {
537
612
  totals.context = promptSize + (turn.usage.output_tokens || 0);
@@ -708,7 +783,8 @@ export async function runAgent({
708
783
  const done = ` ${toolMark(call.name)} ${c.dim(String(label).slice(0, 100))} ${c.dim(`· ${took}s`)}`;
709
784
  if (row) {
710
785
  tui.clearStatus();
711
- row.update({ status: 'ok', took, preview: previewFor(call.name, output) });
786
+ // The whole output travels with the row (capped), so a click can open it.
787
+ row.update({ status: 'ok', took, preview: previewFor(call.name, output), full: String(output ?? '').slice(0, 200_000) });
712
788
  } else if (tui) { tui.clearStatus(); tui.print(done, 'tool'); } else if (!silent) out(done);
713
789
  tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
714
790
  onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
@@ -761,7 +837,7 @@ export async function runAgent({
761
837
  const bad = ` ${c.red(glyph('✗', 'x'))} ${c.dim(String(label).slice(0, 80))} ${c.dim(`· ${took}s`)} — ${msg.slice(0, 120)}`;
762
838
  if (row) {
763
839
  tui.clearStatus();
764
- row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '' });
840
+ row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '', full: msg.slice(0, 200_000) });
765
841
  } else if (tui) { tui.clearStatus(); tui.print(bad, 'tool'); } else if (!silent) out(bad);
766
842
  tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: msg.slice(0, 2000) });
767
843
  onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: false, error: msg });
@@ -16,7 +16,7 @@
16
16
 
17
17
  import path from 'node:path';
18
18
  import { c, out, askKey, glyph } from '../ui.js';
19
- import { decide, ruleForCall, broaderRule, DEFAULT_DENY, subjectOf } from './rules.js';
19
+ import { decide, ruleForCall, broaderRule, DEFAULT_DENY, subjectOf, wideAllows } from './rules.js';
20
20
 
21
21
  export class PermissionDenied extends Error {}
22
22
 
@@ -44,6 +44,8 @@ export function createPermissions({
44
44
  const api = {
45
45
  get mode() { return current; },
46
46
  get rules() { return { allow: [...savedAllow], deny: [...savedDeny], session: [...sessionAllow] }; },
47
+ /** Saved rules that take a whole tool out of the mode's hands (see wideAllows). */
48
+ get wide() { return wideAllows(savedAllow); },
47
49
 
48
50
  /** Add a rule. `persist` writes it to settings via onRulesChange. */
49
51
  addRule(rule, { persist = false, kind = 'allow' } = {}) {
@@ -206,15 +206,39 @@ export function ruleForCall(toolName, input, cwd) {
206
206
  return `${toolName}(${relative && !relative.startsWith('..') ? relative : subject})`;
207
207
  }
208
208
 
209
- /** A broader rule, offered beside the exact one: the whole directory, or the tool. */
209
+ /**
210
+ * A broader rule, offered beside the exact one: the file's directory. Never the whole
211
+ * tool. "Always allow every run command" used to be the second option on every command
212
+ * prompt; one click on it saved a rule that approves every command in that folder in
213
+ * every mode, forever — after which Ask, Auto-edit and Bypass all behaved the same and
214
+ * nothing on screen said why. A tool-wide rule can still be written on purpose with
215
+ * `/permissions allow run_command`; it is just never one click away.
216
+ */
210
217
  export function broaderRule(toolName, input, cwd) {
211
218
  const subject = subjectOf(toolName, input);
212
219
  if (['write_file', 'edit_file', 'read_file', 'read_image', 'list_files'].includes(toolName) && subject) {
213
220
  const relative = cwd ? normalise(path.relative(cwd, path.resolve(cwd, subject))) : subject;
214
221
  const dir = relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '';
215
- return dir && !dir.startsWith('..') ? `${toolName}(${dir}/**)` : `${toolName}`;
222
+ if (dir && !dir.startsWith('..')) return `${toolName}(${dir}/**)`;
223
+ }
224
+ return ruleForCall(toolName, input, cwd);
225
+ }
226
+
227
+ /**
228
+ * Saved allow rules that approve a whole tool — `run_command`, `edit_file(**)` — for the
229
+ * tools a mode is about. While one exists, the mode no longer decides that tool, so the
230
+ * mode's label is only half the truth; callers say so beside it.
231
+ */
232
+ export function wideAllows(allow = []) {
233
+ const GATED = ['run_command', 'background_run', 'write_file', 'edit_file'];
234
+ const out = [];
235
+ for (const rule of allow) {
236
+ const parsed = typeof rule === 'string' ? parseRule(rule) : rule;
237
+ if (!parsed || !GATED.includes(parsed.tool)) continue;
238
+ const p = parsed.pattern;
239
+ if (p === null || p === '' || p === '*' || p === '**' || p === '**/*') out.push(parsed.source);
216
240
  }
217
- return `${toolName}`;
241
+ return out;
218
242
  }
219
243
 
220
244
  /**
@@ -26,6 +26,18 @@ const MAX_READ_LINES = 2_000;
26
26
 
27
27
  export class ToolError extends Error {}
28
28
 
29
+ /**
30
+ * Keep the start AND the end. For a test run or a build the end is where the failures and
31
+ * the summary are; cutting from the front kept the noise and threw away the answer.
32
+ */
33
+ function truncateMiddle(text, limit) {
34
+ const s = String(text ?? '');
35
+ if (s.length <= limit) return s;
36
+ const head = Math.floor(limit / 3);
37
+ const tail = limit - head;
38
+ return `${s.slice(0, head)}\n\n[… ${s.length - limit} characters cut from the middle …]\n\n${s.slice(-tail)}`;
39
+ }
40
+
29
41
  function truncate(text, limit = MAX_OUTPUT) {
30
42
  const s = String(text ?? '');
31
43
  if (s.length <= limit) return s;
@@ -560,8 +572,10 @@ export const TOOLS = [
560
572
  // the joined text cut the exit code off the end of any long output, so the
561
573
  // model could not tell whether the command had worked.
562
574
  const parts = [];
563
- const stdout = truncate(out.text().trim(), 40_000);
564
- const stderr = truncate(err.text().trim(), 16_000);
575
+ // 30k in all (the common agent limit for shell output): every later request
576
+ // re-sends it until it is cleared.
577
+ const stdout = truncateMiddle(out.text().trim(), 22_000);
578
+ const stderr = truncateMiddle(err.text().trim(), 8_000);
565
579
  if (stdout) parts.push(stdout);
566
580
  if (stderr) parts.push(`stderr:\n${stderr}`);
567
581
  if (note) parts.push(note);
@@ -832,6 +846,29 @@ export const TOOLS = [
832
846
  return `Stopped ${input.id}.`;
833
847
  },
834
848
  },
849
+
850
+ {
851
+ // The model loads a skill's instructions itself when a task matches one. Only each
852
+ // skill's one-line description rides along with every request; the body (a few
853
+ // thousand tokens) is paid for only in conversations that need it.
854
+ name: 'load_skill',
855
+ description: 'Load the full instructions of a skill listed under "Skills" in the system prompt. Call it BEFORE starting a task that matches a skill\'s description, then follow what it says.',
856
+ needsPermission: false,
857
+ input_schema: {
858
+ type: 'object',
859
+ properties: { name: { type: 'string', description: 'The skill name, e.g. frontend-design.' } },
860
+ required: ['name'],
861
+ },
862
+ summarise: (i) => `skill ${i.name}`,
863
+ async run(input, { cwd }) {
864
+ const { discoverSkills } = await import('../skills.js');
865
+ const skills = discoverSkills(cwd || process.cwd());
866
+ const name = String(input.name || '').replace(/^\//, '').trim();
867
+ const skill = skills[name];
868
+ if (!skill) throw new ToolError(`No skill named ${name}. Available: ${Object.keys(skills).join(', ') || 'none'}.`);
869
+ return `Skill: ${skill.name}\n\n${skill.content}`;
870
+ },
871
+ },
835
872
  ];
836
873
 
837
874
  /** The live task list todo_write maintains; the session renders it in the panel. */
package/src/api.js CHANGED
@@ -18,7 +18,9 @@ export function createClient() {
18
18
  const { baseUrl } = endpoints();
19
19
  if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
20
20
 
21
- const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli' };
21
+ // x-jaitus-features: what this build understands, so the gateway can use newer error
22
+ // codes (a used-up usage window) without an older CLI reading them as "went wrong".
23
+ const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli', 'x-jaitus-features': 'usage-window' };
22
24
  const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
23
25
 
24
26
  return {
package/src/errors.js CHANGED
@@ -18,7 +18,8 @@ const CATALOG = {
18
18
  rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
19
19
  context_too_long: { title: 'Conversation too long', hint: 'Run /compact, or start a new session.' },
20
20
  request_too_large: { title: 'Request too large', hint: 'Usually images: remove some, or run /compact to drop old ones. Images over 3.5 MB are shrunk automatically.' },
21
- daily_limit_reached: { title: 'Daily limit reached', hint: 'Your plan\'s daily allowance resets tomorrow. /usage shows where you are.' },
21
+ daily_limit_reached: { title: 'Usage limit reached', hint: 'It refills automatically. /usage shows where you are.' },
22
+ window_limit_reached: { title: 'This usage window is used up', hint: 'It refills when the window ends — the rest of your month is still there. /usage shows when.' },
22
23
  token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
23
24
  refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
24
25
  content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },