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/src/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  endpoints, version, HOME, HISTORY,
18
18
  } from './config.js';
19
19
  import { runAgent, repairToolPairs } from './agent/loop.js';
20
- import { createPermissions } from './agent/permissions.js';
20
+ import { createPermissions, MODES as PERMISSION_MODES } from './agent/permissions.js';
21
21
  import { DEFAULT_DENY } from './agent/rules.js';
22
22
  import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
23
23
  import { shrinkImageFile } from './tui/clipboard.js';
@@ -311,6 +311,17 @@ function requireAuth({ quiet = false } = {}) {
311
311
  */
312
312
  let activeTui = null;
313
313
 
314
+ // Failures caused by the message itself: sending it again would fail again, so a turn
315
+ // that failed this way is taken out of the conversation instead of kept with its error.
316
+ const MESSAGE_FAULTS = new Set(['content_blocked', 'request_too_large', 'free_request_too_large', 'context_too_long', 'bad_request']);
317
+
318
+ /** The typed text of a message, whatever its shape. */
319
+ function textOfMessage(m) {
320
+ if (!m) return '';
321
+ if (typeof m.content === 'string') return m.content;
322
+ return (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type === 'text').map((b) => b.text).join('\n');
323
+ }
324
+
314
325
  function reportAgentError(e, context = {}) {
315
326
  const error = e instanceof Error ? e : new Error(String(e?.message ?? e ?? 'Unknown error'));
316
327
  if (activeTui?.error) {
@@ -361,6 +372,11 @@ async function loadContextBudget(client) {
361
372
  // Compact at this fraction of the budget. Low enough that the summarisation request
362
373
  // itself still fits comfortably, high enough that a normal session never sees it.
363
374
  const AUTO_COMPACT_AT = 0.78;
375
+ // The budget is what the gateway advertises — the providers' limit, or the admin's
376
+ // Context cap when it is lower (Admin → Settings → Plans & credits). That cap is the ONE
377
+ // control for how big a session gets before it is summarised: a second ceiling here
378
+ // would silently override whatever the admin set.
379
+ const compactThreshold = () => CONTEXT_BUDGET * AUTO_COMPACT_AT;
364
380
 
365
381
  // An expanded @mention is capped: pulling a 4 MB lockfile into the prompt because
366
382
  // someone typed `@package-lock.json` would blow the context it is meant to save.
@@ -410,8 +426,20 @@ async function summarise(client, model, messages, focus) {
410
426
  messages: [
411
427
  {
412
428
  role: 'user',
413
- content: 'Summarize this conversation compactly for a successor agent: what was asked, '
414
- + 'what was done (files changed, commands run, outcomes), what remains. Be specific and terse.'
429
+ // Sections a successor needs to carry on without re-asking or re-reading — the
430
+ // same structure agent tools use for their own compaction. Losing the user's
431
+ // exact instructions or where the work stopped is what makes a compacted session
432
+ // feel worse; everything else can be re-read from disk.
433
+ content: 'Summarize this conversation for a successor agent that will continue the work with no other memory of it. '
434
+ + 'Use these headings, and be specific (paths, names, numbers) rather than general:\n'
435
+ + '1. Request — what the user asked for, with their exact words for any instruction, constraint or preference.\n'
436
+ + '2. Corrections — anything the user rejected, corrected or asked to do differently, verbatim where short.\n'
437
+ + '3. Files — every file read or changed: path, what changed or what matters in it.\n'
438
+ + '4. Commands and results — what was run and what it showed (test/build results, errors and how each was fixed).\n'
439
+ + '5. Decisions — what was chosen and why.\n'
440
+ + '6. Current state — exactly what was being done when this summary was made, and what is unfinished.\n'
441
+ + '7. Next step — the single next action, if the work was mid-way.\n'
442
+ + 'Omit headings that have nothing. No preamble.'
415
443
  + (focus ? ` Focus on: ${focus}.` : '')
416
444
  + `\n\n<conversation>\n${transcriptForSummary(messages)}\n</conversation>`,
417
445
  },
@@ -491,6 +519,37 @@ function projectRules(cwd) {
491
519
  return { allow: Array.isArray(saved.allow) ? saved.allow : [], deny: Array.isArray(saved.deny) ? saved.deny : [] };
492
520
  }
493
521
 
522
+ /** The bypass confirmation, wherever bypass is switched on from. */
523
+ const BYPASS_CHOICES = [
524
+ { label: 'Yes, for this session', value: 'y' },
525
+ { label: 'Yes, and start every new session in bypass', value: 'always' },
526
+ { label: 'No, stay put', value: 'n' },
527
+ ];
528
+ const BYPASS_WARNING = [
529
+ 'Writes and shell commands will run with NO permission prompts.',
530
+ 'Use only in a trusted workspace you can afford to rebuild. Deny rules still apply.',
531
+ ];
532
+
533
+ /** The mode every new session starts in; null goes back to "the last one used". */
534
+ function saveDefaultMode(mode) {
535
+ const settings = loadSettings();
536
+ if (mode) settings.defaultMode = mode;
537
+ else delete settings.defaultMode;
538
+ saveSettings(settings);
539
+ }
540
+
541
+ /**
542
+ * What to say beside a mode when a saved rule already approves a whole tool here —
543
+ * otherwise "Ask" is on screen while every command runs unasked, and the modes look
544
+ * like they do nothing.
545
+ */
546
+ function wideRuleNote(permissions, mode) {
547
+ if (mode === 'plan' || mode === 'bypassPermissions') return '';
548
+ const wide = permissions?.wide || [];
549
+ if (!wide.length) return '';
550
+ return `Saved rule ${wide.join(', ')} skips the permission question in every mode here — /permissions forget ${wide[0]} to let the mode decide.`;
551
+ }
552
+
494
553
  /** The key rules are saved under: the existing spelling when there is one. */
495
554
  function rulesKey(cwd) {
496
555
  return Object.keys(loadSettings().permissionRules || {}).find((k) => sameDir(k, cwd)) || path.resolve(cwd);
@@ -536,8 +595,11 @@ async function oneShot(task, { flags }) {
536
595
  messages.push({ role: 'user', content: task });
537
596
 
538
597
  const explicitPlan = flags.permissionModeExplicit && flags.permissionMode === 'plan';
598
+ // The saved default mode applies here too — "every session" includes `jaitus -p`.
599
+ const savedDefault = loadSettings().defaultMode;
600
+ const oneShotMode = !flags.permissionModeExplicit && PERMISSION_MODES.includes(savedDefault) ? savedDefault : flags.permissionMode;
539
601
  const permissions = createPermissions({
540
- autoApprove: flags.yes && !explicitPlan, mode: flags.permissionMode,
602
+ autoApprove: flags.yes && !explicitPlan, mode: oneShotMode,
541
603
  interactive: false, ...projectRules(flags.cwd),
542
604
  });
543
605
 
@@ -547,7 +609,7 @@ async function oneShot(task, { flags }) {
547
609
  ...(flags.maxTurns ? { maxIterations: flags.maxTurns } : {}),
548
610
  silent: true,
549
611
  systemOverride: flags.systemPrompt || null,
550
- extraSystem: flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : '',
612
+ extraSystem: `${skillCatalog(discoverSkills(flags.cwd))}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
551
613
  onText: (text) => {
552
614
  answer += text;
553
615
  if (format === 'stream-json') out(JSON.stringify({ type: 'content_block_delta', delta: { text } }));
@@ -602,7 +664,7 @@ const SLASH = {
602
664
  '/tasks': { summary: 'review tool activity from this session' },
603
665
  '/image <path>': { summary: 'attach an image (png/jpg/gif/webp) the model will see with your next message' },
604
666
  '/memory [text]': { summary: 'show or append to JAITUS.md (# does this inline)' },
605
- '/permissions [mode|rules]': { summary: 'permission mode, and the allow/deny rules for this project' },
667
+ '/permissions [mode|rules|default]': { summary: 'permission mode, the mode new sessions start in, and the allow/deny rules for this project' },
606
668
  '/skills': { summary: 'list project workflows' },
607
669
  '/sessions': { summary: 'list saved sessions in this directory' },
608
670
  '/session': { summary: 'show this session id', hidden: true }, // the id is already in /status
@@ -767,11 +829,21 @@ async function session({ flags }) {
767
829
  // flag used to be overridden by it, so after one Shift+Tab `--permission-mode plan`
768
830
  // started a session that edited files without asking. Plan (often set by /ask or
769
831
  // /plan, not chosen) and bypass never carry into a new session on their own.
770
- const lastMode = loadSettings().lastMode;
771
- if (lastMode && !flags.yes && !flags.permissionModeExplicit && ['default', 'acceptEdits'].includes(lastMode)) permissions.setMode(lastMode);
832
+ // A saved default mode (`/permissions default <mode>`, or "Yes, every session" on the
833
+ // bypass prompt) is a choice made once, on purpose, and it wins over the last mode:
834
+ // turning bypass on in every new chat was the thing people had to repeat.
835
+ const startSettings = loadSettings();
836
+ const defaultMode = PERMISSION_MODES.includes(startSettings.defaultMode) ? startSettings.defaultMode : null;
837
+ const lastMode = startSettings.lastMode;
838
+ if (!flags.yes && !flags.permissionModeExplicit) {
839
+ if (defaultMode) permissions.setMode(defaultMode);
840
+ else if (['default', 'acceptEdits'].includes(lastMode)) permissions.setMode(lastMode);
841
+ }
842
+ const bypassIsDefault = defaultMode === 'bypassPermissions' && !flags.permissionModeExplicit;
772
843
  // Starting straight into bypass gets one explicit confirmation (§11) when a
773
- // human is present; headless runs just get the warning on stderr.
774
- if (permissions.mode === 'bypassPermissions' && !flags.yes) {
844
+ // human is present; headless runs just get the warning on stderr. A saved bypass
845
+ // default was confirmed when it was saved and is not asked again.
846
+ if (permissions.mode === 'bypassPermissions' && !flags.yes && !bypassIsDefault) {
775
847
  if (process.stdin.isTTY && process.stdout.isTTY) {
776
848
  out(`\n ${c.red(c.bold('● BYPASS'))} ${c.red('requested — writes and shell commands would run with no permission checks.')}`);
777
849
  const answer = await askKey(` ${c.dim(`Enable bypass mode for this session? [y/N] ${glyph('›', '>')}`)} `, ['y', 'n', '\r', ' ']);
@@ -789,7 +861,7 @@ async function session({ flags }) {
789
861
  // continues its count instead of starting again from zero.
790
862
  const saved = sessionState.spent || {};
791
863
  const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
792
- const spent = { input: num(saved.input), cached: num(saved.cached), output: num(saved.output), tools: num(saved.tools), turns: num(saved.turns) };
864
+ const spent = { input: num(saved.input), cached: num(saved.cached), output: num(saved.output), tools: num(saved.tools), turns: num(saved.turns), credits: num(saved.credits) };
793
865
  sessionState.spent = spent;
794
866
  // The real size of the last prompt, as the provider counted it; 0 until one is known.
795
867
  let lastContext = num(sessionState.lastContext);
@@ -862,17 +934,15 @@ async function session({ flags }) {
862
934
  const order = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
863
935
  const next = order[(order.indexOf(permissions.mode) + 1) % order.length];
864
936
  if (next === 'bypassPermissions') {
865
- const ans = await tui.askDialog('Enable bypass mode?', [
866
- { label: 'Yes, skip all checks', value: 'y' },
867
- { label: 'No, stay put', value: 'n' },
868
- ], [
869
- 'Writes and shell commands will run with NO permission prompts.',
870
- 'Use only in a trusted workspace you can afford to rebuild.',
871
- ]);
872
- if (dialogValue(ans) !== 'y') return permissions.mode;
937
+ const ans = await tui.askDialog('Enable bypass mode?', BYPASS_CHOICES, BYPASS_WARNING);
938
+ const picked = dialogValue(ans);
939
+ if (picked !== 'y' && picked !== 'always') return permissions.mode;
940
+ if (picked === 'always') saveDefaultMode('bypassPermissions');
873
941
  }
874
942
  const m = permissions.cycleMode();
875
943
  refreshStatusLine('mode');
944
+ const note = wideRuleNote(permissions, m);
945
+ if (note) tui.flash?.(note, 'warn', 4500);
876
946
  return m;
877
947
  },
878
948
  };
@@ -964,6 +1034,7 @@ async function session({ flags }) {
964
1034
  spent.input += result.input || 0;
965
1035
  spent.cached += result.cached || 0;
966
1036
  spent.output += result.output || 0;
1037
+ spent.credits = Math.round(((spent.credits || 0) + (result.credits || 0)) * 100) / 100;
967
1038
  spent.tools += result.tools || 0;
968
1039
  try { tui?.setSpent?.(spent); } catch { /* header keeps last value */ }
969
1040
  }
@@ -989,7 +1060,12 @@ async function session({ flags }) {
989
1060
  const result = await runAgent({
990
1061
  client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
991
1062
  onContext: (n) => { lastContext = n; sessionState.lastContext = n; showContext(); },
992
- extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
1063
+ // `jaitus config trimToolResults off` keeps every old tool output in context.
1064
+ trimContext: loadSettings().trimToolResults !== false,
1065
+ // What happened since the model's last turn (a failed request, an interrupt, a
1066
+ // compaction, a blocked hook), told once. This was recorded all along and never
1067
+ // read — the model never heard about any of it.
1068
+ extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}${ledger.drain()}`,
993
1069
  systemOverride: flags.systemPrompt || null,
994
1070
  });
995
1071
  // An interrupted turn still spent what it spent before Esc — count it.
@@ -997,6 +1073,7 @@ async function session({ flags }) {
997
1073
  spent.input += result.input || 0;
998
1074
  spent.cached += result.cached || 0;
999
1075
  spent.output += result.output || 0;
1076
+ spent.credits = Math.round(((spent.credits || 0) + (result.credits || 0)) * 100) / 100;
1000
1077
  spent.tools += result.tools || 0;
1001
1078
  if (result.context) { lastContext = result.context; sessionState.lastContext = lastContext; }
1002
1079
  }
@@ -1026,9 +1103,21 @@ async function session({ flags }) {
1026
1103
  // that, its next turn begins as though nothing went wrong and it re-attempts the
1027
1104
  // thing that just failed.
1028
1105
  if (ctrl.signal.aborted) lastRunInterrupted = true;
1106
+ const what = isJaitusError(e) ? `${e.title} — ${e.message}` : 'an unexpected error inside the CLI';
1029
1107
  if (messages.length === startLength && messages[messages.length - 1]?.role === 'user') {
1030
- messages.pop();
1031
- ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''}: ${e.message}. That turn was discarded and the user's message was not answered.`);
1108
+ if (MESSAGE_FAULTS.has(e?.code)) {
1109
+ // The message ITSELF was the problem (refused content, too large): sending it
1110
+ // again with every later request would make every one of them fail too. It
1111
+ // stays out, and the model is told what was asked and why it was rejected.
1112
+ const failed = messages.pop();
1113
+ const preview = e.code === 'content_blocked' ? '' : ` It began: "${textOfMessage(failed).replace(/\s+/g, ' ').slice(0, 240)}"`;
1114
+ ledger.note('turn-failed', `The user's previous message got no answer: it was rejected (${what}) and removed from the conversation, because sending it again would fail again.${preview}`);
1115
+ } else {
1116
+ // Anything else — the network, a busy provider, a plan limit — says nothing
1117
+ // about the message. It stays in the conversation, followed by what happened to
1118
+ // it, so the model can see what was asked and pick it up.
1119
+ messages.push({ role: 'assistant', content: `[No reply — this request failed: ${what}]` });
1120
+ }
1032
1121
  } else {
1033
1122
  ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''} partway through the task: ${e.message}. The steps before it are kept; it did not finish.`);
1034
1123
  }
@@ -1079,7 +1168,7 @@ async function session({ flags }) {
1079
1168
  const autoCompactIfNeeded = async () => {
1080
1169
  if (loadSettings().autoCompact === false) return;
1081
1170
  const used = estimateTokens(messages);
1082
- if (used < CONTEXT_BUDGET * AUTO_COMPACT_AT) return;
1171
+ if (used < compactThreshold()) return;
1083
1172
  if (messages.length < 4) return;
1084
1173
 
1085
1174
  tui.setStatus?.(`Compacting — ${format(used)} of ${format(CONTEXT_BUDGET)} context used`);
@@ -1396,6 +1485,15 @@ async function session({ flags }) {
1396
1485
  showContext();
1397
1486
  tui.setSpent?.(spent);
1398
1487
  refreshStatusLine('start');
1488
+ // Why the session is in the mode it is in, when that is not obvious from the badge.
1489
+ const startNotes = [
1490
+ bypassIsDefault && permissions.mode === 'bypassPermissions' ? 'Bypass is your default for new sessions — /permissions default ask turns that off.' : '',
1491
+ wideRuleNote(permissions, permissions.mode),
1492
+ ].filter(Boolean);
1493
+ // The welcome screen hides transcript notices until the first message, so the status
1494
+ // line carries them at start; the notice keeps the record in the transcript.
1495
+ for (const note of startNotes) { if (tui.notice) tui.notice(note, 'warn'); else tui.print(c.yellow(` ${note}`)); }
1496
+ if (startNotes.length) tui.flash?.(startNotes.join(' · '), 'warn', 9000);
1399
1497
 
1400
1498
  /** Esc+Esc: pick a checkpoint, restore the conversation and the files. */
1401
1499
  const doRewind = async () => {
@@ -1698,7 +1796,7 @@ async function slashCommand(verb, args, ctx) {
1698
1796
  [` ${c.dim('account')}`, creds?.user?.email || 'not signed in'],
1699
1797
  [` ${c.dim('directory')}`, ctx.flags.cwd],
1700
1798
  [` ${c.dim('context')}`, `${ctx.messages.length} messages · ~${estimateTokens(ctx.messages).toLocaleString()} tokens`],
1701
- [` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls`],
1799
+ [` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls${ctx.spent.credits ? ` · ${Math.round(ctx.spent.credits * 10) / 10} credits` : ''}`],
1702
1800
  ]);
1703
1801
  return null;
1704
1802
  }
@@ -1861,17 +1959,15 @@ async function slashCommand(verb, args, ctx) {
1861
1959
  const applyMode = async (next) => {
1862
1960
  const real = next === 'auto' ? 'bypassPermissions' : next;
1863
1961
  if ((real === 'bypassPermissions') && ctx.tui && ctx.permissions.mode !== 'bypassPermissions') {
1864
- const ans = await ctx.tui.askDialog('Enable bypass mode?', [
1865
- { label: 'Yes, skip all checks', value: 'y' },
1866
- { label: 'No, stay put', value: 'n' },
1867
- ], [
1868
- 'Writes and shell commands will run with NO permission prompts.',
1869
- 'Use only in a trusted workspace you can afford to rebuild.',
1870
- ], { title: 'Bypass mode', hideComment: true });
1871
- if (dialogValue(ans) !== 'y') { info(`Staying in ${c.bold(ctx.permissions.mode)}.`); return; }
1962
+ const ans = await ctx.tui.askDialog('Enable bypass mode?', BYPASS_CHOICES, BYPASS_WARNING, { title: 'Bypass mode', hideComment: true });
1963
+ const picked = dialogValue(ans);
1964
+ if (picked !== 'y' && picked !== 'always') { info(`Staying in ${c.bold(ctx.permissions.mode)}.`); return; }
1965
+ if (picked === 'always') { saveDefaultMode('bypassPermissions'); ok('Bypass is now the default for every new session — /permissions default ask undoes it.'); }
1872
1966
  }
1873
1967
  ctx.permissions.setMode(real);
1874
1968
  ok(`Permission mode: ${c.bold(next)}`);
1969
+ const note = wideRuleNote(ctx.permissions, real);
1970
+ if (note) warn(note);
1875
1971
  if (real === 'bypassPermissions') warn('The agent will not ask before writing files or running commands.');
1876
1972
  if (next === 'plan') info('The agent can read and think, but nothing runs until you switch out of plan mode.');
1877
1973
  };
@@ -1880,6 +1976,27 @@ async function slashCommand(verb, args, ctx) {
1880
1976
  // shows what has accumulated, which matters because "always allow" writes to
1881
1977
  // disk and a list nobody can read is a list nobody can revoke.
1882
1978
  const next = args[0];
1979
+ // ── the mode every new session starts in ──
1980
+ if (next === 'default' && args.length > 1 || next === 'startup') {
1981
+ const wanted = normaliseMode(args[1]) || (/^(off|none|last|unset)$/i.test(args[1] || '') ? 'off' : null);
1982
+ if (!wanted) {
1983
+ const current = loadSettings().defaultMode;
1984
+ info(`New sessions start in ${c.bold(current || 'the last mode used (ask or auto-edit)')}.`);
1985
+ info(`${c.dim('Change it with')} ${c.bold('/permissions default bypass')}${c.dim(', ask, acceptEdits, plan — or off.')}`);
1986
+ return null;
1987
+ }
1988
+ if (wanted === 'bypassPermissions' && ctx.tui && loadSettings().defaultMode !== 'bypassPermissions') {
1989
+ const ans = await ctx.tui.askDialog('Start every new session in bypass?', [
1990
+ { label: 'Yes, every session', value: 'y' },
1991
+ { label: 'No', value: 'n' },
1992
+ ], BYPASS_WARNING, { title: 'Bypass mode', hideComment: true });
1993
+ if (dialogValue(ans) !== 'y') { info('Unchanged.'); return null; }
1994
+ }
1995
+ saveDefaultMode(wanted === 'off' ? null : wanted);
1996
+ ok(wanted === 'off' ? 'New sessions start in the last mode used (ask or auto-edit).' : `New sessions start in ${c.bold(wanted)}.`);
1997
+ if (wanted !== 'off' && ctx.permissions.mode !== wanted) info(`This session stays in ${c.bold(ctx.permissions.mode)} — Shift+Tab or /permissions ${wanted} to switch now.`);
1998
+ return null;
1999
+ }
1883
2000
  if (next === 'rules' || next === 'allow' || next === 'deny' || next === 'forget') {
1884
2001
  const { parseRule } = await import('./agent/rules.js');
1885
2002
  if (next === 'rules') {
@@ -1912,8 +2029,10 @@ async function slashCommand(verb, args, ctx) {
1912
2029
  }
1913
2030
 
1914
2031
  if (next) {
1915
- if (!Object.keys(MODES).concat('auto').includes(next)) { warn(`Unknown mode: ${next}. Try a mode, or ${c.bold('/permissions rules')}.`); return null; }
1916
- await applyMode(next);
2032
+ // `bypass`, `ask`, `auto-edit` are what the badge says; they are modes too.
2033
+ const named = normaliseMode(next);
2034
+ if (!named) { warn(`Unknown mode: ${next}. Try ask, acceptEdits, plan, bypass — or ${c.bold('/permissions rules')}.`); return null; }
2035
+ await applyMode(named);
1917
2036
  return null;
1918
2037
  }
1919
2038
  if (!ctx.tui) {
@@ -1982,7 +2101,7 @@ async function slashCommand(verb, args, ctx) {
1982
2101
  // sessionState.spent and every reader hold this same object.
1983
2102
  const t = target.spent || {};
1984
2103
  const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
1985
- Object.assign(ctx.spent, { input: n(t.input), cached: n(t.cached), output: n(t.output), tools: n(t.tools), turns: n(t.turns) });
2104
+ Object.assign(ctx.spent, { input: n(t.input), cached: n(t.cached), output: n(t.output), tools: n(t.tools), turns: n(t.turns), credits: n(t.credits) });
1986
2105
  ctx.resetContext?.();
1987
2106
  ctx.tui?.setSpent?.(ctx.spent);
1988
2107
  if (ctx.tui) replaySession(ctx.tui, target.messages);
@@ -2528,11 +2647,12 @@ async function slashCommand(verb, args, ctx) {
2528
2647
  table([
2529
2648
  [' Turns', String(spent.turns)],
2530
2649
  [' Tool calls', String(spent.tools)],
2650
+ [' Credits', spent.credits ? String(Math.round(spent.credits * 100) / 100) : '—'],
2531
2651
  [' Input tokens', spent.input.toLocaleString()],
2532
2652
  [' Cached input tokens', (spent.cached || 0).toLocaleString()],
2533
2653
  [' Output tokens', spent.output.toLocaleString()],
2534
2654
  ]);
2535
- info(`${c.dim('Billed usage is what your account records — see')} ${c.bold('jaitus usage')}.`);
2655
+ info(`${c.dim('Credits are what your plan counts: real cost, cached context included at its lower price. See')} ${c.bold('/usage')} ${c.dim('for your plan.')}`);
2536
2656
  return null;
2537
2657
 
2538
2658
  case '/cwd':
@@ -2565,9 +2685,18 @@ function planRows(u) {
2565
2685
  const st = u.planStatus || null;
2566
2686
  if (!u.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} used`]];
2567
2687
  const rows = [['Plan', st?.name || 'Paid']];
2568
- if (st?.monthlyTokens) {
2688
+ // Credits: what the plan counts (real cost), the same number the website shows.
2689
+ const cr = (n) => ((Number(n) || 0) >= 1000 ? Math.round(n).toLocaleString('en-IN') : String(Math.round((Number(n) || 0) * 10) / 10));
2690
+ if (st?.monthlyCredits) {
2569
2691
  const resets = st.periodResetsAt ? ` · resets ${new Date(st.periodResetsAt).toLocaleDateString()}` : '';
2570
- rows.push(['This period', `${fmtTokens(st.periodTokens)} of ${fmtTokens(st.monthlyTokens)} tokens${resets}`]);
2692
+ rows.push(['This period', `${cr(st.periodCredits)} of ${cr(st.monthlyCredits)} credits${resets}`]);
2693
+ if (st.windowCredits) {
2694
+ const refill = st.windowResetsAt
2695
+ ? `refills at ${new Date(st.windowResetsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
2696
+ : 'opens with your next request';
2697
+ rows.push([`This ${st.windowHours || 5}h window`, `${cr(st.windowUsed)} of ${cr(st.windowCredits)} credits · ${refill}`]);
2698
+ }
2699
+ if (st.periodTokens) rows.push(['Behind them', `${fmtTokens(st.periodTokens)} tokens of input and replies`]);
2571
2700
  }
2572
2701
  if (u.planExpiresAt) rows.push(['Renews by', new Date(u.planExpiresAt).toLocaleDateString()]);
2573
2702
  return rows;
@@ -2720,9 +2849,10 @@ const commands = {
2720
2849
  'jaitus config theme <name> dark, light, ocean, daltonized, auto',
2721
2850
  'jaitus config <key> --unset back to the default',
2722
2851
  'jaitus config copyOnSelect <on|off> auto-copy mouse selection',
2852
+ 'jaitus config defaultMode bypass start every session in bypass (ask, acceptEdits, plan; --unset = last mode used)',
2723
2853
  ],
2724
2854
  detail: [
2725
- 'Settable: model, baseUrl, siteUrl, theme, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, mouse, statusLine,',
2855
+ 'Settable: model, baseUrl, siteUrl, theme, defaultMode, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, trimToolResults, mouse, statusLine,',
2726
2856
  'braveSearchKey, tavilyKey, serperKey. Stored in ~/.jaitus/settings.json.',
2727
2857
  '',
2728
2858
  'A search API key is the single biggest improvement available to web_search: the free',
@@ -2737,7 +2867,7 @@ const commands = {
2737
2867
  const unset = rest.length === 1 && /^(--unset|none|unset)$/i.test(rest[0]);
2738
2868
  const value = unset ? '' : rest.join(' ').trim();
2739
2869
  const settings = loadSettings();
2740
- const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'mouse', 'autoUpdate'];
2870
+ const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'trimToolResults', 'mouse', 'autoUpdate'];
2741
2871
  const SECRET = ['braveSearchKey', 'tavilyKey', 'serperKey'];
2742
2872
  if (!key) {
2743
2873
  const e = endpoints();
@@ -2746,6 +2876,7 @@ const commands = {
2746
2876
  website: e.siteUrl,
2747
2877
  model: settings.model || null,
2748
2878
  theme: settings.theme || 'auto',
2879
+ defaultMode: settings.defaultMode || null,
2749
2880
  statusLine: settings.statusLine || null,
2750
2881
  copyOnSelect: settings.copyOnSelect !== false,
2751
2882
  scrollSpeed: settings.scrollSpeed ?? 1,
@@ -2753,6 +2884,7 @@ const commands = {
2753
2884
  autoScroll: settings.autoScroll !== false,
2754
2885
  animations: settings.animations !== false,
2755
2886
  autoCompact: settings.autoCompact !== false,
2887
+ trimToolResults: settings.trimToolResults !== false,
2756
2888
  mouse: settings.mouse !== false,
2757
2889
  // Presence and length only. A key echoed into a transcript is a key that has
2758
2890
  // been published, and the transcript is on disk.
@@ -2764,12 +2896,14 @@ const commands = {
2764
2896
  ['Website', data.website],
2765
2897
  ['Model', data.model || 'whatever the gateway lists first'],
2766
2898
  ['Theme', `${data.theme} (${[...THEME_NAMES, 'auto'].join(', ')})`],
2899
+ ['Start mode', data.defaultMode || 'the last mode used (ask or auto-edit)'],
2767
2900
  ['Copy on select', data.copyOnSelect ? 'on (drag selects, auto-copies)' : 'off (Ctrl+Shift+C copies)'],
2768
2901
  ['Scroll speed', String(data.scrollSpeed)],
2769
2902
  ['Wheel accel', data.wheelAccel ? 'on' : 'off'],
2770
2903
  ['Auto-scroll', data.autoScroll ? 'on' : 'off'],
2771
2904
  ['Animations', data.animations ? 'on' : 'off'],
2772
- ['Auto-compact', data.autoCompact ? 'on (summarises near the context limit)' : 'off'],
2905
+ ['Auto-compact', data.autoCompact ? 'on (summarises at 78% of the context size the service allows)' : 'off'],
2906
+ ['Trim old tool output', data.trimToolResults ? 'on (clears old outputs past ~40k tokens, keeps the last 6)' : 'off'],
2773
2907
  ['Mouse', data.mouse ? 'on (wheel scrolls, clicks act, drag or double-click copies)' : 'off (the terminal selects text itself)'],
2774
2908
  ['Search API', data.searchApi ? `${data.searchApi} (set)` : 'none — using free engines + npm/GitHub/MDN/Stack Overflow'],
2775
2909
  ['Status line', data.statusLine || '(unset — set a command to render one)'],
@@ -2777,13 +2911,13 @@ const commands = {
2777
2911
  ]));
2778
2912
  return 0;
2779
2913
  }
2780
- if (!['model', 'baseUrl', 'siteUrl', 'theme', 'statusLine', ...ONOFF, ...SECRET, 'scrollSpeed'].includes(key)) {
2781
- fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
2914
+ if (!['model', 'baseUrl', 'siteUrl', 'theme', 'defaultMode', 'statusLine', ...ONOFF, ...SECRET, 'scrollSpeed'].includes(key)) {
2915
+ fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, defaultMode, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
2782
2916
  return 1;
2783
2917
  }
2784
2918
  // Clearing a value (there was no way: an empty value just printed the current one).
2785
2919
  if (unset) {
2786
- if (!['model', 'baseUrl', 'siteUrl', 'statusLine', 'theme', 'scrollSpeed', ...SECRET].includes(key)) { fail(`${key} is on/off — set it with on or off.`); return 1; }
2920
+ if (!['model', 'baseUrl', 'siteUrl', 'statusLine', 'theme', 'defaultMode', 'scrollSpeed', ...SECRET].includes(key)) { fail(`${key} is on/off — set it with on or off.`); return 1; }
2787
2921
  delete settings[key];
2788
2922
  saveSettings(settings);
2789
2923
  ok(`${key} cleared — back to the default.`);
@@ -2799,6 +2933,15 @@ const commands = {
2799
2933
  if (key === 'scrollSpeed') { out(String(settings.scrollSpeed ?? 1)); return 0; }
2800
2934
  out(String(settings[key] ?? '')); return 0;
2801
2935
  }
2936
+ if (key === 'defaultMode') {
2937
+ const mode = normaliseMode(value);
2938
+ if (!mode) { fail('Use ask, acceptEdits, plan or bypass (or --unset for the last mode used).'); return 1; }
2939
+ settings.defaultMode = mode;
2940
+ saveSettings(settings);
2941
+ ok(`defaultMode = ${mode} — every new session starts in it`);
2942
+ if (mode === 'bypassPermissions') warn('Writes and shell commands will run with no permission prompts in every session. Deny rules still apply.');
2943
+ return 0;
2944
+ }
2802
2945
  if (key === 'theme') {
2803
2946
  // The themes that exist (ocean was refused; plain was accepted and did nothing).
2804
2947
  const names = [...THEME_NAMES, 'auto'];
package/src/sessions.js CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import crypto from 'node:crypto';
9
9
  import { SESSIONS } from './config.js';
10
10
  import { c } from './ui.js';
11
+ import { unifiedDiff } from './diff.js';
11
12
 
12
13
  function safeId(id) {
13
14
  return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
@@ -145,7 +146,9 @@ export function replaySession(tui, messages) {
145
146
  }
146
147
 
147
148
  if (m.role !== 'assistant') continue;
148
- const blocks = Array.isArray(m.content) ? m.content : [];
149
+ // A plain-string assistant turn (a failed request's "[No reply — …]" note) was
150
+ // skipped entirely, as though the turn never happened.
151
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : Array.isArray(m.content) ? m.content : [];
149
152
  const text = blocks.filter((b) => b?.type === 'text' && b.text).map((b) => b.text).join('\n\n');
150
153
  if (text.trim()) {
151
154
  if (structured) tui.assistant(text); else tui.print(`${c.brand('◆')} ${text}`, 'assistant');
@@ -161,7 +164,25 @@ export function replaySession(tui, messages) {
161
164
  status: outcome ? (outcome.failed ? 'failed' : 'ok') : 'ok',
162
165
  file: call.input?.path,
163
166
  error: outcome?.failed ? outcome.text.split('\n')[0].slice(0, 200) : '',
167
+ // The whole result, so the row can be opened again after a resume.
168
+ full: outcome ? outcome.text.slice(0, 200_000) : '',
164
169
  });
170
+ // An edit reads back as the change it made. The file's full before/after is not
171
+ // in the conversation, but the replaced text and its replacement are.
172
+ if (!outcome?.failed && typeof tui.diff === 'function') {
173
+ const input = call.input || {};
174
+ try {
175
+ if (call.name === 'edit_file') {
176
+ const pairs = (Array.isArray(input.edits) ? input.edits : [input])
177
+ .filter((e) => typeof e?.old === 'string' && typeof e?.new === 'string');
178
+ const patch = pairs.map((e) => unifiedDiff(e.old, e.new, { file: input.path })).filter(Boolean).join('\n');
179
+ if (patch) tui.diff(String(input.path || 'edit'), patch);
180
+ } else if (call.name === 'write_file' && typeof input.content === 'string') {
181
+ const patch = unifiedDiff('', input.content, { file: input.path });
182
+ if (patch) tui.diff(String(input.path || 'file'), patch);
183
+ }
184
+ } catch { /* a missing diff never blocks the replay */ }
185
+ }
165
186
  } else {
166
187
  tui.print(` ${c.accent('✓')} ${c.dim(`${call.name} ${label}`.slice(0, 100))}`, 'tool');
167
188
  }
@@ -0,0 +1,91 @@
1
+ ---
2
+ description: Load BEFORE making a PDF or any printable/shareable document — report, proposal, invoice, resume/CV, letter, certificate, brochure, one-pager, handout, slide deck. Produces typeset, professional documents instead of plain text dumps.
3
+ ---
4
+
5
+ # Professional documents and PDFs
6
+
7
+ The reliable route is: **write semantic HTML with print CSS, then print it to PDF with a
8
+ headless browser.** It gives real typography, exact page control and is easy to revise.
9
+ Use a PDF library (reportlab, pdfkit, jsPDF) only if the project already uses one.
10
+
11
+ ## 1. Plan the document
12
+
13
+ - What is it, who reads it, and what must they do after reading? Put that first.
14
+ - Pick a document direction that fits: corporate report (clean sans, blue-black ink, one
15
+ accent, data tables) · editorial/proposal (serif body, generous margins, pull quotes) ·
16
+ modern minimal (one sans, lots of white, strong headings) · formal letter/certificate
17
+ (centred serif, restrained ornament) · resume (dense but calm, two-column, no graphics
18
+ that ATS software cannot read).
19
+ - Outline the sections before writing; every page should have one clear purpose.
20
+
21
+ ## 2. Page setup (print CSS)
22
+
23
+ ```css
24
+ @page { size: A4; margin: 20mm 18mm 22mm; } /* or: size: Letter; */
25
+ @page :first { margin-top: 0; } /* full-bleed cover if wanted */
26
+ html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
27
+ body { font: 10.5pt/1.55 "Source Serif 4", Georgia, serif; color: #1b1d22; }
28
+ h1, h2, h3 { font-family: "Inter Tight", "Helvetica Neue", Arial, sans-serif; line-height: 1.15; break-after: avoid; }
29
+ h2 { break-before: auto; margin: 18pt 0 6pt; }
30
+ table, figure, blockquote, .card { break-inside: avoid; }
31
+ p { orphans: 3; widows: 3; }
32
+ .page-break { break-before: page; }
33
+ a { color: inherit; text-decoration: none; border-bottom: 0.5pt solid currentColor; }
34
+ ```
35
+
36
+ - Use pt/mm for print, never viewport units. Body 10–11.5pt, headings on a scale
37
+ (e.g. 10.5 · 13 · 16 · 22 · 32pt). Line length ~65–80 characters; wide pages use two
38
+ columns (`columns: 2; column-gap: 8mm;`) or a wide margin column for notes.
39
+ - Colour: dark ink on white, ONE accent colour for headings rules, key numbers and table
40
+ headers, tinted greys for secondary text. Keep it printable in greyscale.
41
+ - Page numbers / running footer: put a footer element on each page section, or when the
42
+ browser supports it use `@page { @bottom-right { content: counter(page) " / " counter(pages); } }`.
43
+
44
+ ## 3. Typesetting details that make it look professional
45
+
46
+ - A proper cover or title block: title, subtitle, author/organisation, date — aligned to a
47
+ grid, not just a big centred heading.
48
+ - Consistent vertical rhythm: spacing in multiples of the body line height.
49
+ - Tables: left-align text, right-align numbers with tabular figures
50
+ (`font-variant-numeric: tabular-nums`), light horizontal rules only, a bold or tinted
51
+ header row, units in the header, totals row emphasised. No full grid of borders.
52
+ - Numbers: thousands separators, consistent decimals, currency symbols (₹ 1,49,900 for
53
+ Indian format when the audience is Indian).
54
+ - Callouts/key figures as a small highlighted block, not colourful boxes everywhere.
55
+ - Charts: inline SVG (or Chart.js rendered before printing) with direct labels, no 3D, no
56
+ heavy gridlines, the accent colour for the series that matters and grey for the rest.
57
+ - Real content — no lorem ipsum, no "[Your Name]" left in unless it is a template.
58
+ - Invoices: seller/buyer blocks, invoice number, dates, line items table, tax lines, total,
59
+ payment details and terms — every legally expected field for the country.
60
+ - Resumes: name large, contact on one line, sections in a clear order, consistent date
61
+ alignment on the right, verbs and numbers in bullets, one page unless senior.
62
+
63
+ ## 4. Render to PDF
64
+
65
+ Save the HTML (e.g. `report.html`) and print it with whichever browser is installed:
66
+
67
+ ```sh
68
+ # Windows (Edge ships with Windows)
69
+ "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --headless --disable-gpu --no-pdf-header-footer --print-to-pdf="report.pdf" "file:///C:/path/to/report.html"
70
+ # Chrome (any OS)
71
+ chrome --headless --disable-gpu --no-pdf-header-footer --print-to-pdf=report.pdf file:///abs/path/report.html
72
+ # macOS Chrome
73
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless --no-pdf-header-footer --print-to-pdf=report.pdf file:///abs/path/report.html
74
+ ```
75
+
76
+ If the project has Node and Playwright or Puppeteer, `page.pdf({ format: 'A4',
77
+ printBackground: true, preferCSSPageSize: true })` works the same. Web fonts must finish
78
+ loading before printing — with a headless browser add `--virtual-time-budget=5000`, or
79
+ embed the fonts locally.
80
+
81
+ ## 5. Check before handing over
82
+
83
+ 1. The PDF exists and opens; report its path and page count.
84
+ 2. No heading stranded at the bottom of a page, no table row split, no blank last page.
85
+ 3. Margins are even, nothing clipped at the edges, backgrounds printed.
86
+ 4. Hierarchy is obvious at a glance; one accent colour; consistent spacing.
87
+ 5. All placeholder text replaced; numbers, dates and names are correct.
88
+
89
+ For slide decks: one idea per slide, 16:9 (`@page { size: 297mm 167mm; margin: 0 }` with
90
+ one `section` per slide), a big headline that states the point, at most ~30 words of body,
91
+ visual consistency across slides, and slide numbers.