@quatcode/agent 0.2.9 → 0.2.11

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/tui.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { readFile, stat } from "node:fs/promises";
4
- import { Session, ToolRegistry, addUsage, emptyUsage, estimateCost, runAgent, } from "@quatcode/core";
4
+ import { Session, ToolRegistry, addUsage, compactMessages, contextWindowFor, emptyUsage, estimateCost, runAgent, } from "@quatcode/core";
5
5
  import { analyzeCommand, createProjectTools, McpManager } from "@quatcode/tools";
6
6
  import { buildProjectContext, createBrainTools, scanProject } from "@quatcode/brain";
7
7
  import { Keyboard } from "./keyboard.js";
@@ -14,7 +14,7 @@ import { renderMarkdown, wrapSegments } from "./markdown.js";
14
14
  import { ADD_PROVIDER, allProviders, buildProvider, defaultModelFor, effortsForModel, hasKey, keyEnvFor, probeProviderModels, providerEnvKey, providerHasModel, providerMeta, providerSlug, registerCustomProvider, removeCustomProvider, resolveProviderForModel, } from "./models.js";
15
15
  import { ASCII_MODE, activeThemeId, bg, bold, buildColorRemap, clipAnsi, fg, getThemes, glyphs, mix, padEndTo, palette, pulse, remapColors, setTheme, shimmer, stringWidth, stripAnsi, tone, toolGlyph, } from "./theme.js";
16
16
  import { listSessions, listSessionsForDir, loadConfig, makeSessionId, saveConfig, saveSession, deleteSession } from "./store.js";
17
- import { configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sameDir, sortConversations, updateConversation } from "./store.js";
17
+ import { appendAudit, auditInput, configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sameDir, sortConversations, updateConversation } from "./store.js";
18
18
  import { MCP_USAGE, isMcpUrl, mcpInstructionsSection, parseMcpAdd } from "./mcp.js";
19
19
  import { AUTO, PERSONA_SETTINGS, personaSection, personaSummary, } from "./persona.js";
20
20
  import { copyToClipboard, readClipboardImage, readClipboardText, } from "./clipboard.js";
@@ -35,6 +35,7 @@ const COMMAND_INFO = [
35
35
  { name: "/timeline", desc: "jump to a sent message" },
36
36
  { name: "/undo", desc: "take back the last message" },
37
37
  { name: "/verbose", desc: "toggle full tool output" },
38
+ { name: "/compact", desc: "summarize older history to free context" },
38
39
  { name: "/settings", desc: "tune the agent's persona: character, tone, verbosity…" },
39
40
  { name: "/skills", args: "[add|remove|list]", desc: "manage agent skills" },
40
41
  { name: "/mcp", args: "[list|add|remove]", desc: "manage MCP servers" },
@@ -110,6 +111,8 @@ class TuiApp {
110
111
  permissionInner = 0;
111
112
  permissionBorder = palette.steel;
112
113
  lastVisibleStart = 0;
114
+ lastContextTokens = 0;
115
+ lastCachedTokens = 0;
113
116
  lastInputRender = null;
114
117
  lastInputOffset = 1;
115
118
  lastHeaderRows = 2;
@@ -1982,21 +1985,50 @@ class TuiApp {
1982
1985
  this.append([
1983
1986
  ` ${tone.amber(glyphs.diamondOpen)} ${tone.mist(`// ${req.tool} blocked in PLAN mode (Tab switches to BUILD)`)}`,
1984
1987
  ]);
1988
+ this.audit(req, "denied", "plan");
1985
1989
  return { allowed: false };
1986
1990
  }
1987
1991
  if (this.agentMode === "plan" && READ_ONLY_TOOLS.has(req.tool))
1988
1992
  return { allowed: true };
1989
- if (this.gateMode === "always")
1993
+ const analysis = req.tool === "run_command" ? this.commandAnalysis(req) : null;
1994
+ const dangerous = analysis?.dangerous === true;
1995
+ if (this.opts.allowAll) {
1996
+ this.audit(req, "allowed", "allow-all", analysis);
1990
1997
  return { allowed: true };
1991
- const choice = await this.permissionCard(req);
1992
- if (choice === "always") {
1993
- this.gateMode = "always";
1998
+ }
1999
+ // "always" never covers dangerous commands — those are confirmed each time.
2000
+ if (this.gateMode === "always" && !dangerous) {
2001
+ this.audit(req, "allowed", "always", analysis);
1994
2002
  return { allowed: true };
1995
2003
  }
1996
- return { allowed: choice === "allow" };
2004
+ const choice = await this.permissionCard(req);
2005
+ const granted = choice === "allow" || choice === "always";
2006
+ if (choice === "always" && !dangerous)
2007
+ this.gateMode = "always";
2008
+ this.audit(req, granted ? "allowed" : "denied", choice, analysis);
2009
+ return { allowed: granted };
1997
2010
  },
1998
2011
  };
1999
2012
  }
2013
+ commandAnalysis(req) {
2014
+ const command = req.input && typeof req.input === "object" && "command" in req.input
2015
+ ? String(req.input.command)
2016
+ : "";
2017
+ return command ? analyzeCommand(command) : null;
2018
+ }
2019
+ audit(req, decision, via, analysis) {
2020
+ void appendAudit({
2021
+ ts: new Date().toISOString(),
2022
+ dir: this.dir,
2023
+ tool: req.tool,
2024
+ decision,
2025
+ via,
2026
+ danger: analysis
2027
+ ? { dangerous: analysis.dangerous, sensitive: analysis.sensitive, reasons: analysis.reasons }
2028
+ : undefined,
2029
+ input: auditInput(req.input),
2030
+ }).catch(() => undefined);
2031
+ }
2000
2032
  async promptForKey(keyEnv) {
2001
2033
  this.append([` ${tone.mist(`// ${keyEnv} IS NOT SET`)}`]);
2002
2034
  const result = await this.readLine({
@@ -2055,7 +2087,7 @@ class TuiApp {
2055
2087
  this.undoAfterAbort = false;
2056
2088
  this.mode = "running";
2057
2089
  this.scrollOffset = 0;
2058
- const controller = new TurnController({ model: this.model, maxSteps: this.opts.maxSteps }, {
2090
+ const controller = new TurnController({ model: this.model, maxSteps: this.opts.maxSteps, contextWindow: this.contextLimit() }, {
2059
2091
  width: () => this.contentWidth() - 2,
2060
2092
  append: (lines) => this.append(lines),
2061
2093
  beginLive: () => this.beginLive(),
@@ -2079,6 +2111,7 @@ class TuiApp {
2079
2111
  stream: true,
2080
2112
  signal: this.abort.signal,
2081
2113
  images,
2114
+ contextLimit: this.contextLimit(),
2082
2115
  onEvent: controller.handle,
2083
2116
  });
2084
2117
  }
@@ -2117,6 +2150,8 @@ class TuiApp {
2117
2150
  }
2118
2151
  this.totalUsage = addUsage(this.totalUsage, result.usage);
2119
2152
  this.turns += 1;
2153
+ this.lastContextTokens = controller.contextTokens;
2154
+ this.lastCachedTokens = controller.cachedTokens;
2120
2155
  const status = this.abortRequested
2121
2156
  ? "interrupted"
2122
2157
  : result.status === "complete"
@@ -2219,6 +2254,9 @@ class TuiApp {
2219
2254
  case "settings":
2220
2255
  await this.openSettings();
2221
2256
  return false;
2257
+ case "compact":
2258
+ await this.compactCommand();
2259
+ return false;
2222
2260
  case "theme":
2223
2261
  await this.changeTheme(arg);
2224
2262
  return false;
@@ -2239,6 +2277,50 @@ class TuiApp {
2239
2277
  return false;
2240
2278
  }
2241
2279
  }
2280
+ contextLimit() {
2281
+ const flag = this.opts.contextLimit;
2282
+ if (typeof flag === "number" && flag > 0)
2283
+ return flag;
2284
+ const env = Number(process.env.QUATCODE_CONTEXT_LIMIT);
2285
+ if (Number.isFinite(env) && env > 0)
2286
+ return env;
2287
+ const configured = this.config.contextLimit;
2288
+ if (typeof configured === "number" && configured > 0)
2289
+ return configured;
2290
+ return contextWindowFor(this.model);
2291
+ }
2292
+ async compactCommand() {
2293
+ const provider = this.ensureProvider();
2294
+ if (!provider) {
2295
+ this.append([
2296
+ ` ${tone.red(glyphs.cross)} ${tone.mist("// COMPACT needs a configured provider")}`,
2297
+ ]);
2298
+ return;
2299
+ }
2300
+ const before = this.session.getMessages().length;
2301
+ this.setStatus({ label: "compacting context" });
2302
+ try {
2303
+ const result = await compactMessages(this.session.getMessages(), {
2304
+ provider,
2305
+ model: this.model,
2306
+ });
2307
+ if (!result) {
2308
+ this.append([` ${tone.mist("// nothing to compact yet")}`]);
2309
+ return;
2310
+ }
2311
+ this.session.replaceMessages(result.messages);
2312
+ this.append([
2313
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// CONTEXT COMPACTED · ${before} → ${result.messages.length} messages · ~${fmtTokens(result.tokensBefore)} → ~${fmtTokens(result.tokensAfter)} tokens`)}`,
2314
+ "",
2315
+ ]);
2316
+ }
2317
+ catch (err) {
2318
+ this.append([` ${tone.red(`// compact failed: ${errorMessage(err)}`)}`]);
2319
+ }
2320
+ finally {
2321
+ this.setStatus(null);
2322
+ }
2323
+ }
2242
2324
  async openSettings() {
2243
2325
  const before = JSON.stringify(this.persona);
2244
2326
  for (;;) {
@@ -2967,11 +3049,17 @@ class TuiApp {
2967
3049
  }
2968
3050
  printUsage() {
2969
3051
  const cost = estimateCost(this.totalUsage, this.model, this.providerId);
3052
+ const window = this.contextLimit();
3053
+ const context = this.lastContextTokens > 0
3054
+ ? `${fmtTokens(this.lastContextTokens)} / ${fmtTokens(window)} (${Math.round((this.lastContextTokens / window) * 100)}%)`
3055
+ : "—";
2970
3056
  const rows = [
2971
3057
  ["turns", String(this.turns)],
3058
+ ["context", context],
3059
+ ["cached", this.lastCachedTokens > 0 ? `${fmtTokens(this.lastCachedTokens)} read` : "—"],
2972
3060
  [
2973
3061
  "tokens",
2974
- `${fmtTokens(this.totalUsage.inputTokens)}↑ ${fmtTokens(this.totalUsage.outputTokens)}↓`,
3062
+ `${fmtTokens(this.totalUsage.inputTokens)}↑ ${fmtTokens(this.totalUsage.outputTokens)}↓ (session)`,
2975
3063
  ],
2976
3064
  ["cost", fmtCost(cost)],
2977
3065
  ["elapsed", fmtDuration(performance.now() - this.startedAt)],