This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git

commit c85ae36949f00dda50adf3ea0d1af459b554eccf
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 8 22:46:19 2026 +0200

    camel-jbang-plugin-tui: AI panel defers history compaction on local 
endpoints
    
    After each answered turn the panel shrank older tool results to 400 chars.
    That saves counted tokens, which is what a hosted API bills for, but a
    local server (Ollama) keeps the KV cache of the previous request: a request
    that only extends the conversation pays for its new tokens (~0.2s), while
    rewriting an earlier message forces the tail from that point to be
    processed again (measured 1-2s per question with qwen3.6:35b-a3b on an M4
    Pro, which also cannot reuse a partial prefix due to its hybrid attention).
    
    With a local endpoint the history is now left untouched until it is
    estimated to exceed 16k tokens, half the 32k window the client requests,
    and compacted as before beyond that. Hosted endpoints and /compact are
    unchanged.
    
    camel-jbang: AI log shows prompt-cache signal per answer (prefill/gen time, 
cached tokens)
    
    Token counts cannot tell whether a prompt was served from the provider's
    cache: Ollama reports the full prompt size either way. What the providers
    do reveal is now carried in TokenUsage and shown on the AI log's Response
    line:
    
    - Ollama: prompt_eval_duration and eval_duration, logged as
      'prefill 0.2s, gen 2.6s'; a cached prompt is a near-zero prefill
    - OpenAI (prompt_tokens_details.cached_tokens), Anthropic
      (cache_read_input_tokens) and Gemini (cachedContentTokenCount): logged
      as 'cached 3.2k'
    
    The three-argument TokenUsage constructor is kept, so existing callers are
    unchanged.
    
    camel-jbang-plugin-tui: tui_get_status adds uptimeText next to the millis 
uptime
    
    The context section of the status document holds uptime in milliseconds
    without saying so, and the human form (3h13m) only exists in the dev
    console's text output and in what the TUI and camel ps compute for the
    screen. A small model reading the raw section guessed the unit and
    reported 13,800,803 ms as 13.8 seconds. The status tool now adds
    uptimeText next to every numeric uptime and says so in its description;
    route entries already carry text and are left alone.
    
    camel-console: context dev console does not count routes created by Kamelets
    
    routesTotal/routesStarted (and the Routes: line of the text output) came
    from the ManagedCamelContext MBean, which counts every route in the
    context, while the route dev console lists only JMX-managed routes, and
    routes created by a Kamelet are not registered with JMX by default. The
    two disagreed whenever a Kamelet was in use (3 routes vs 1 for a single
    route consuming from a Kamelet), which confused tooling and the TUI AI
    panel alike.
    
    The context console now counts routes the way the route console lists
    them: Kamelet-created routes are left out unless the management agent is
    configured to register them. The MBean attributes are unchanged. Upgrade
    guide note added; generated metadata and catalog regenerated.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../apache/camel/catalog/dev-consoles-openapi.json |  4 +-
 .../apache/camel/catalog/dev-consoles/context.json |  4 +-
 .../org/apache/camel/dev-console/context.json      |  4 +-
 .../camel/impl/console/ContextDevConsole.java      | 51 +++++++++++++++---
 .../ContextDevConsoleKameletRoutesTest.java        | 61 ++++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 10 ++++
 .../camel/dsl/jbang/core/commands/LlmClient.java   | 52 +++++++++++++++---
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 59 +++++++++++++++++++--
 .../jbang/core/commands/tui/TuiToolRegistry.java   | 28 +++++++++-
 .../commands/tui/AiPanelHistoryCompactionTest.java | 17 ++++++
 .../dsl/jbang/core/commands/tui/AiPanelTest.java   | 16 ++++++
 .../tui/TuiToolRegistryUptimeTextTest.java         | 60 +++++++++++++++++++++
 12 files changed, 342 insertions(+), 24 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
index 5a1454ef76ca..e3d001e3183e 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json
@@ -1090,11 +1090,11 @@
                                                                                
        "properties": {
                                                                                
                "routesTotal": {
                                                                                
                        "type": "integer",
-                                                                               
                        "description": "Total number of routes"
+                                                                               
                        "description": "Total number of routes (routes created 
by Kamelets are not counted unless the management agent registers them)"
                                                                                
                },
                                                                                
                "routesStarted": {
                                                                                
                        "type": "integer",
-                                                                               
                        "description": "Number of started routes"
+                                                                               
                        "description": "Number of started routes (routes 
created by Kamelets are not counted unless the management agent registers them)"
                                                                                
                },
                                                                                
                "load01": {
                                                                                
                        "type": "string",
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json
index abf65937f19a..a5f047a34731 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/context.json
@@ -56,11 +56,11 @@
         "properties": {
           "routesTotal": {
             "type": "integer",
-            "description": "Total number of routes"
+            "description": "Total number of routes (routes created by Kamelets 
are not counted unless the management agent registers them)"
           },
           "routesStarted": {
             "type": "integer",
-            "description": "Number of started routes"
+            "description": "Number of started routes (routes created by 
Kamelets are not counted unless the management agent registers them)"
           },
           "load01": {
             "type": "string",
diff --git 
a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json
 
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json
index abf65937f19a..a5f047a34731 100644
--- 
a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json
+++ 
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/context.json
@@ -56,11 +56,11 @@
         "properties": {
           "routesTotal": {
             "type": "integer",
-            "description": "Total number of routes"
+            "description": "Total number of routes (routes created by Kamelets 
are not counted unless the management agent registers them)"
           },
           "routesStarted": {
             "type": "integer",
-            "description": "Number of started routes"
+            "description": "Number of started routes (routes created by 
Kamelets are not counted unless the management agent registers them)"
           },
           "load01": {
             "type": "string",
diff --git 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java
 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java
index 64f08a622c65..29a8d90fbea6 100644
--- 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java
+++ 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ContextDevConsole.java
@@ -23,13 +23,19 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 
+import org.apache.camel.CamelContext;
 import org.apache.camel.ContextEvents;
+import org.apache.camel.Route;
+import org.apache.camel.ServiceStatus;
 import org.apache.camel.api.management.ManagedCamelContext;
 import org.apache.camel.api.management.mbean.ManagedCamelContextMBean;
 import org.apache.camel.clock.Clock;
+import org.apache.camel.spi.ManagementAgent;
+import org.apache.camel.spi.ManagementStrategy;
 import org.apache.camel.spi.Metadata;
 import org.apache.camel.spi.ReloadStrategy;
 import org.apache.camel.spi.ResourceReloadStrategy;
+import org.apache.camel.spi.RouteController;
 import org.apache.camel.spi.annotations.DevConsole;
 import org.apache.camel.support.CamelContextHelper;
 import org.apache.camel.support.ExceptionHelper;
@@ -52,8 +58,10 @@ public class ContextDevConsole extends AbstractDevConsole {
     }
 
     public record Statistics(
-            @Metadata(description = "Total number of routes") int routesTotal,
-            @Metadata(description = "Number of started routes") int 
routesStarted,
+            @Metadata(description = "Total number of routes (routes created by 
Kamelets are not counted unless the "
+                                    + "management agent registers them)") int 
routesTotal,
+            @Metadata(description = "Number of started routes (routes created 
by Kamelets are not counted unless the "
+                                    + "management agent registers them)") int 
routesStarted,
             @Metadata(description = "1 minute load average (only present when 
available)") String load01,
             @Metadata(description = "5 minute load average (only present when 
available)") String load05,
             @Metadata(description = "15 minute load average (only present when 
available)") String load15,
@@ -120,9 +128,8 @@ public class ContextDevConsole extends AbstractDevConsole {
         if (mcc != null) {
             ManagedCamelContextMBean mb = mcc.getManagedCamelContext();
             if (mb != null) {
-                int total = mb.getTotalRoutes();
-                int started = mb.getStartedRoutes();
-                sb.append(String.format("%n    Routes: %s (started: %s)", 
total, started));
+                int[] routes = countRoutes(getCamelContext());
+                sb.append(String.format("%n    Routes: %s (started: %s)", 
routes[0], routes[1]));
 
                 int reloaded = 0;
                 int reloadedFailed = 0;
@@ -212,6 +219,37 @@ public class ContextDevConsole extends AbstractDevConsole {
         return JsonRecordSupport.toJsonObject(response);
     }
 
+    /**
+     * Counts routes the way the route console lists them. A Kamelet is 
implemented as a route inside the Kamelet, and
+     * such routes are not registered with JMX by default, so the route 
console does not show them; they are left out
+     * here too so the totals match what the routes list shows, unless the 
management agent is configured to register
+     * Kamelet routes (in which case both include them). The MBean totals, by 
contrast, count every route.
+     *
+     * @return total and started route counts
+     */
+    static int[] countRoutes(CamelContext camelContext) {
+        boolean includeKamelet = false;
+        ManagementStrategy ms = camelContext.getManagementStrategy();
+        ManagementAgent agent = ms != null ? ms.getManagementAgent() : null;
+        if (agent != null && 
Boolean.TRUE.equals(agent.getRegisterRoutesCreateByKamelet())) {
+            includeKamelet = true;
+        }
+        int total = 0;
+        int started = 0;
+        RouteController controller = camelContext.getRouteController();
+        for (Route route : camelContext.getRoutes()) {
+            if (!includeKamelet && route.isCreatedByKamelet()) {
+                continue;
+            }
+            total++;
+            ServiceStatus status = 
controller.getRouteStatus(route.getRouteId());
+            if (status != null && status.isStarted()) {
+                started++;
+            }
+        }
+        return new int[] { total, started };
+    }
+
     private Statistics buildStatistics(ManagedCamelContextMBean mb) {
         String load1 = getLoad1(mb);
         String load5 = getLoad5(mb);
@@ -260,8 +298,9 @@ public class ContextDevConsole extends AbstractDevConsole {
         }
         Reload reload = new Reload(reloaded, reloadedFailed, lastError);
 
+        int[] routes = countRoutes(getCamelContext());
         return new Statistics(
-                mb.getTotalRoutes(), mb.getStartedRoutes(),
+                routes[0], routes[1],
                 hasLoad ? load1 : null, hasLoad ? load5 : null, hasLoad ? 
load15 : null,
                 thp.isEmpty() ? null : thp,
                 mb.getIdleSince(), mb.getExchangesTotal(), 
mb.getExchangesFailed(), mb.getExchangesInflight(),
diff --git 
a/core/camel-console/src/test/java/org/apache/camel/impl/console/ContextDevConsoleKameletRoutesTest.java
 
b/core/camel-console/src/test/java/org/apache/camel/impl/console/ContextDevConsoleKameletRoutesTest.java
new file mode 100644
index 000000000000..25fc19ffe823
--- /dev/null
+++ 
b/core/camel-console/src/test/java/org/apache/camel/impl/console/ContextDevConsoleKameletRoutesTest.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.impl.console;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.RouteDefinition;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Routes created by a Kamelet are internal to the Kamelet and not registered 
with JMX by default, so the route console
+ * does not list them; the context console's route counts must agree with that 
list rather than with the raw number of
+ * routes in the context. The statistics themselves need camel-management on 
the classpath, which this module's tests do
+ * not have, so the counting is asserted directly.
+ */
+public class ContextDevConsoleKameletRoutesTest extends ContextTestSupport {
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:app").routeId("app").to("mock:app");
+
+                RouteDefinition internal = 
from("direct:internal").routeId("kamelet-internal");
+                internal.to("mock:internal");
+                internal.setKamelet(true);
+            }
+        };
+    }
+
+    @Test
+    public void testKameletRoutesAreNotCountedAsRoutes() throws Exception {
+        Assertions.assertEquals(2, context.getRoutes().size(), "both routes 
exist in the context");
+        
Assertions.assertTrue(context.getRoute("kamelet-internal").isCreatedByKamelet());
+
+        int[] counts = ContextDevConsole.countRoutes(context);
+        Assertions.assertEquals(1, counts[0], "total leaves out the Kamelet 
route");
+        Assertions.assertEquals(1, counts[1], "started leaves out the Kamelet 
route");
+
+        context.getRouteController().stopRoute("app");
+        counts = ContextDevConsole.countRoutes(context);
+        Assertions.assertEquals(1, counts[0]);
+        Assertions.assertEquals(0, counts[1], "a stopped route still counts as 
total but not as started");
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index d845030dfef8..117c059d53c8 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -91,6 +91,16 @@ The Event developer console now exposes the full structured 
JSON payload in the
 each event entry, while keeping the existing flat `type`, `timestamp`, 
`exchangeId`, and
 `message` fields for backwards compatibility.
 
+=== camel-console
+
+The `context` developer console no longer counts routes created by Kamelets in 
its `routesTotal` and `routesStarted`
+statistics (nor in the `Routes:` line of its text output). A Kamelet is 
implemented as a route inside the Kamelet, and
+such routes are not registered with JMX by default, so the `route` developer 
console never listed them; the counts now
+agree with that list. This affects consumers of the context status, such as 
the Camel JBang status document
+(`~/.camel/<pid>-status.json`) and the Camel TUI. If the management agent is 
configured to register Kamelet routes
+(`registerRoutesCreateByKamelet=true`), they are counted as before. The JMX 
`ManagedCamelContext` MBean attributes
+`TotalRoutes` and `StartedRoutes` are unchanged and still count every route.
+
 === camel-dynamic-router
 
 The `dynamic-router-control` endpoint no longer takes the subscription 
`predicate`, or the
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
index 0907110de505..de73bca11aed 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
@@ -124,14 +124,34 @@ public class LlmClient {
         }
     }
 
-    public record TokenUsage(int inputTokens, int outputTokens, int 
totalTokens) {
+    /**
+     * Token usage of one request, plus whatever the provider reveals about 
its prompt cache: hosted APIs report how
+     * many input tokens were served from cache ({@code cachedTokens}: OpenAI 
{@code cached_tokens}, Anthropic
+     * {@code cache_read_input_tokens}, Gemini {@code 
cachedContentTokenCount}), while Ollama reports no cache figure
+     * but does report how long prompt processing and generation took ({@code 
prefillMillis}, {@code generationMillis}),
+     * and a prompt served from its KV cache shows as a near-zero prefill. 
Zero means not reported.
+     */
+    public record TokenUsage(int inputTokens, int outputTokens, int 
totalTokens,
+            int cachedTokens, long prefillMillis, long generationMillis) {
         public static final TokenUsage EMPTY = new TokenUsage(0, 0, 0);
 
+        public TokenUsage(int inputTokens, int outputTokens, int totalTokens) {
+            this(inputTokens, outputTokens, totalTokens, 0, 0, 0);
+        }
+
         public TokenUsage add(TokenUsage other) {
             return new TokenUsage(
                     inputTokens + other.inputTokens,
                     outputTokens + other.outputTokens,
-                    totalTokens + other.totalTokens);
+                    totalTokens + other.totalTokens,
+                    cachedTokens + other.cachedTokens,
+                    prefillMillis + other.prefillMillis,
+                    generationMillis + other.generationMillis);
+        }
+
+        /** Whether the provider reported a prompt-cache figure or a timing 
split. */
+        public boolean hasCacheSignal() {
+            return cachedTokens > 0 || prefillMillis > 0 || generationMillis > 
0;
         }
     }
 
@@ -846,7 +866,7 @@ public class LlmClient {
         if (total == 0) {
             total = input + output;
         }
-        return new TokenUsage(input, output, total);
+        return new TokenUsage(input, output, total, getIntValue(usageMetadata, 
"cachedContentTokenCount"), 0, 0);
     }
 
     private JsonObject sendGeminiRequest(String requestUrl, JsonObject body) {
@@ -1040,6 +1060,7 @@ public class LlmClient {
             List<ToolCall> toolCalls = new ArrayList<>();
             String[] doneReasonHolder = { null };
             int[] tokenHolder = { 0, 0 };
+            long[] durationHolder = { 0, 0 };
 
             response.body().forEach(line -> {
                 if (line.isBlank()) {
@@ -1091,6 +1112,8 @@ public class LlmClient {
                         doneReasonHolder[0] = chunk.getString("done_reason");
                         tokenHolder[0] = getIntValue(chunk, 
"prompt_eval_count");
                         tokenHolder[1] = getIntValue(chunk, "eval_count");
+                        durationHolder[0] = getLongValue(chunk, 
"prompt_eval_duration") / 1_000_000;
+                        durationHolder[1] = getLongValue(chunk, 
"eval_duration") / 1_000_000;
                     }
                 } catch (Exception e) {
                     // skip malformed chunks
@@ -1105,7 +1128,9 @@ public class LlmClient {
             String stopReason
                     = !toolCalls.isEmpty() ? "tool_calls" : 
(doneReasonHolder[0] != null ? doneReasonHolder[0] : "stop");
 
-            TokenUsage usage = new TokenUsage(tokenHolder[0], tokenHolder[1], 
tokenHolder[0] + tokenHolder[1]);
+            TokenUsage usage = new TokenUsage(
+                    tokenHolder[0], tokenHolder[1], tokenHolder[0] + 
tokenHolder[1], 0,
+                    durationHolder[0], durationHolder[1]);
             if (verbose) {
                 printer.println("[verbose] Streamed Ollama: text=" + (text != 
null ? truncateVerbose(text) : "null")
                                 + ", toolCalls=" + toolCalls.size() + ", 
doneReason=" + doneReasonHolder[0]
@@ -1453,7 +1478,10 @@ public class LlmClient {
 
         int inputTokens = getIntValue(response, "prompt_eval_count");
         int outputTokens = getIntValue(response, "eval_count");
-        TokenUsage usage = new TokenUsage(inputTokens, outputTokens, 
inputTokens + outputTokens);
+        TokenUsage usage = new TokenUsage(
+                inputTokens, outputTokens, inputTokens + outputTokens, 0,
+                getLongValue(response, "prompt_eval_duration") / 1_000_000,
+                getLongValue(response, "eval_duration") / 1_000_000);
 
         if (verbose) {
             printer.println("[verbose] Parsed Ollama: text=" + (content != 
null ? truncateVerbose(content) : "null")
@@ -1502,10 +1530,12 @@ public class LlmClient {
         int prompt = getIntValue(usage, "prompt_tokens");
         int completion = getIntValue(usage, "completion_tokens");
         int total = getIntValue(usage, "total_tokens");
+        int cached = usage.get("prompt_tokens_details") instanceof JsonObject 
details
+                ? getIntValue(details, "cached_tokens") : 0;
         if (total == 0) {
             total = prompt + completion;
         }
-        return new TokenUsage(prompt, completion, total);
+        return new TokenUsage(prompt, completion, total, cached, 0, 0);
     }
 
     private TokenUsage extractAnthropicUsage(JsonObject response) {
@@ -1515,7 +1545,7 @@ public class LlmClient {
         }
         int input = getIntValue(usage, "input_tokens");
         int output = getIntValue(usage, "output_tokens");
-        return new TokenUsage(input, output, input + output);
+        return new TokenUsage(input, output, input + output, 
getIntValue(usage, "cache_read_input_tokens"), 0, 0);
     }
 
     private static int getIntValue(JsonObject obj, String key) {
@@ -1526,6 +1556,14 @@ public class LlmClient {
         return 0;
     }
 
+    private static long getLongValue(JsonObject obj, String key) {
+        Object val = obj.get(key);
+        if (val instanceof Number n) {
+            return n.longValue();
+        }
+        return 0;
+    }
+
     private String extractOpenAiContent(JsonObject response) {
         if (response == null) {
             return null;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index 7bad44131449..fc3561aba9f4 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -29,6 +29,7 @@ import java.util.Deque;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
@@ -96,6 +97,16 @@ class AiPanel {
      * Oldest turns are dropped beyond this many user questions in one 
conversation.
      */
     static final int MAX_HISTORY_TURNS = 20;
+    /**
+     * With a local endpoint the history is left untouched until it is 
estimated to exceed this many tokens. A local
+     * server (Ollama) keeps the KV cache of the previous request, so a 
request that merely extends the conversation
+     * only pays for its new tokens, whereas rewriting an earlier message 
forces the whole tail from that point to be
+     * processed again (measured at one to two seconds per question with a 35B 
MoE model on Apple silicon, against 0.2s
+     * when the history is untouched). Compacting saves counted tokens, which 
is what a hosted API bills for, but costs
+     * time locally, so it is deferred until the context actually needs the 
room: half of the 32k window the client
+     * requests from Ollama, leaving space for the static prefix and the 
current turn's tool results.
+     */
+    static final int LOCAL_HISTORY_BUDGET_TOKENS = 16_000;
     private static final int MAX_LOG_ENTRIES = 200;
     private static final DateTimeFormatter TIME_FMT
             = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
@@ -1135,7 +1146,8 @@ class AiPanel {
                     String tokenInfo = totalUsage.totalTokens() > 0
                             ? ", " + 
LlmClient.formatTokens(totalUsage.totalTokens()) + " tokens"
                             : "";
-                    log(LogLevel.RESPONSE, "Response (" + elapsed + "s" + 
tokenInfo + ")", text);
+                    log(LogLevel.RESPONSE, "Response (" + elapsed + "s" + 
tokenInfo + describeCacheSignal(totalUsage) + ")",
+                            text);
                 } else {
                     String err = "Empty response from LLM.";
                     conversation.add(new ConversationEntry(AiRole.ERROR, err));
@@ -1143,7 +1155,7 @@ class AiPanel {
                 }
                 scrollOffset = 0;
                 messages.add(LlmClient.Message.assistantWithToolCalls(text, 
List.of()));
-                compactHistory(messages, MAX_HISTORY_TURNS, 
COMPACT_TOOL_RESULT_CHARS);
+                compactHistoryAfterTurn();
                 return;
             }
         }
@@ -1162,7 +1174,48 @@ class AiPanel {
         // keep the history consistent: the turn ends without an answer, so 
the next question starts fresh from here
         messages.add(LlmClient.Message.assistantWithToolCalls(
                 "(no answer: the iteration limit was reached while calling 
tools)", List.of()));
-        compactHistory(messages, MAX_HISTORY_TURNS, COMPACT_TOOL_RESULT_CHARS);
+        compactHistoryAfterTurn();
+    }
+
+    /**
+     * Compacts the history after a turn unless the endpoint is local and the 
history is still within
+     * {@link #LOCAL_HISTORY_BUDGET_TOKENS}; see there for why rewriting 
history is the slower choice locally.
+     * {@code /compact} bypasses this and always compacts.
+     */
+    private void compactHistoryAfterTurn() {
+        boolean local = client != null && client.isLocalEndpoint();
+        if (shouldCompactAfterTurn(local, historyChars(messages))) {
+            compactHistory(messages, MAX_HISTORY_TURNS, 
COMPACT_TOOL_RESULT_CHARS);
+        }
+    }
+
+    static boolean shouldCompactAfterTurn(boolean localEndpoint, long 
historyChars) {
+        return !localEndpoint || estimateTokens(historyChars) > 
LOCAL_HISTORY_BUDGET_TOKENS;
+    }
+
+    /**
+     * What the provider revealed about its prompt cache for the request(s) of 
a question, for the AI log: with Ollama
+     * the time spent on prompt processing versus generation (a cached prompt 
shows as a near-zero prefill even though
+     * the token count always reports the full prompt), with hosted APIs how 
many input tokens came from the cache.
+     * Empty when the provider reported nothing.
+     */
+    static String describeCacheSignal(LlmClient.TokenUsage usage) {
+        if (usage == null || !usage.hasCacheSignal()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        if (usage.prefillMillis() > 0 || usage.generationMillis() > 0) {
+            sb.append(", prefill 
").append(formatSeconds(usage.prefillMillis()))
+                    .append(", gen 
").append(formatSeconds(usage.generationMillis()));
+        }
+        if (usage.cachedTokens() > 0) {
+            sb.append(", cached 
").append(LlmClient.formatTokens(usage.cachedTokens()));
+        }
+        return sb.toString();
+    }
+
+    private static String formatSeconds(long millis) {
+        return String.format(Locale.ROOT, "%.1fs", millis / 1000.0);
     }
 
     private static String summarize(String text, int max) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 8b58e480f158..043e9ef58d31 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -36,6 +36,7 @@ import org.apache.camel.tooling.model.ComponentModel;
 import org.apache.camel.tooling.model.DataFormatModel;
 import org.apache.camel.tooling.model.EipModel;
 import org.apache.camel.tooling.model.LanguageModel;
+import org.apache.camel.util.TimeUtils;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
 import org.apache.camel.util.json.Jsoner;
@@ -362,8 +363,9 @@ class TuiToolRegistry {
         tools.add(toToolDef(toolDef(
                 "tui_get_status",
                 "One top-level section of the integration's full status 
document (~/.camel/<pid>-status.json). "
-                                  + "Use it for data no tab shows: context 
(name, version, state, uptime millis, "
-                                  + "startTimestamp, statistics), runtime 
(pid, directory, java), healthChecks, "
+                                  + "Use it for data no tab shows: context 
(name, version, state, uptime in millis "
+                                  + "with uptimeText human readable, 
startTimestamp, statistics), runtime (pid, "
+                                  + "directory, java), healthChecks, "
                                   + "properties, main-configuration, 
routeController, services, transformers, rests, "
                                   + "consumers, producers, endpoints, 
dataSources, memory, threads, gc, classLoading, "
                                   + "trace, events. section='sections' lists 
them. Request only the section you need.",
@@ -1356,6 +1358,7 @@ class TuiToolRegistry {
         if (value == null) {
             return "Unknown section '" + section + "' for PID " + pid + ". 
Available: " + String.join(", ", sections);
         }
+        addUptimeText(value);
         JsonObject result = new JsonObject();
         result.put("pid", pid);
         result.put("section", section);
@@ -1363,6 +1366,27 @@ class TuiToolRegistry {
         return Jsoner.serialize(result);
     }
 
+    /**
+     * Adds a human-readable {@code uptimeText} ("3h13m") next to every 
numeric {@code uptime}, which the status
+     * document holds in milliseconds without saying so; a small model 
otherwise guesses the unit (13,800,803 was read
+     * as 13.8 seconds). The route entries already carry their uptime as text 
and are left alone.
+     */
+    static void addUptimeText(Object value) {
+        if (value instanceof JsonObject jo) {
+            Object uptime = jo.get("uptime");
+            if (uptime instanceof Number n && !jo.containsKey("uptimeText")) {
+                jo.put("uptimeText", TimeUtils.printDuration(n.longValue()));
+            }
+            for (Object child : jo.values()) {
+                addUptimeText(child);
+            }
+        } else if (value instanceof JsonArray arr) {
+            for (Object child : arr) {
+                addUptimeText(child);
+            }
+        }
+    }
+
     private String callAction(Map<String, Object> args) {
         String action = (String) args.get("action");
         if (action == null || action.isBlank()) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java
index 48a9c250a592..9ae95529d22a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelHistoryCompactionTest.java
@@ -24,6 +24,7 @@ import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -46,6 +47,22 @@ class AiPanelHistoryCompactionTest {
         return message.toolResults().get(0).content();
     }
 
+    @Test
+    void hostedEndpointsCompactAfterEveryTurn() {
+        assertTrue(AiPanel.shouldCompactAfterTurn(false, 0));
+        assertTrue(AiPanel.shouldCompactAfterTurn(false, 1_000));
+    }
+
+    @Test
+    void localEndpointsDeferCompactionUntilTheHistoryBudgetIsExceeded() {
+        long budgetChars = (long) AiPanel.LOCAL_HISTORY_BUDGET_TOKENS * 4;
+        // an untouched history keeps the local server's KV cache valid, so 
nothing is rewritten while it fits
+        assertFalse(AiPanel.shouldCompactAfterTurn(true, 0));
+        assertFalse(AiPanel.shouldCompactAfterTurn(true, budgetChars));
+        // once the history would crowd the context window, compaction resumes 
as for hosted endpoints
+        assertTrue(AiPanel.shouldCompactAfterTurn(true, budgetChars + 4_000));
+    }
+
     @Test
     void truncatesOversizedToolResultsButKeepsShortOnes() {
         String small = "ok";
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index 648eb1f72c0e..14b6798ab36a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -1007,6 +1007,22 @@ class AiPanelTest {
                 .anyMatch(e -> e.role() == AiRole.SYSTEM && 
e.text().contains("AI usage statistics reset")));
     }
 
+    @Test
+    void 
responseLogShowsPrefillAndGenerationTimeForOllamaAndCachedTokensForHostedApis() 
{
+        assertEquals("", AiPanel.describeCacheSignal(new 
LlmClient.TokenUsage(100, 10, 110)));
+        assertEquals("", AiPanel.describeCacheSignal(null));
+        // Ollama: a cached prompt is a near-zero prefill; the token count 
alone cannot tell
+        assertEquals(", prefill 0.2s, gen 2.6s",
+                AiPanel.describeCacheSignal(new LlmClient.TokenUsage(4500, 
130, 4630, 0, 170, 2600)));
+        // hosted API: cache hits are reported as tokens
+        assertEquals(", cached 3.2k",
+                AiPanel.describeCacheSignal(new LlmClient.TokenUsage(4500, 
130, 4630, 3200, 0, 0)));
+        // several round trips add up
+        LlmClient.TokenUsage sum = new LlmClient.TokenUsage(4500, 30, 4530, 0, 
170, 600)
+                .add(new LlmClient.TokenUsage(4800, 130, 4930, 0, 210, 2600));
+        assertEquals(", prefill 0.4s, gen 3.2s", 
AiPanel.describeCacheSignal(sum));
+    }
+
     @Test
     void retryWithoutAQuestionIsRefused() {
         AiPanel panel = new AiPanel();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryUptimeTextTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryUptimeTextTest.java
new file mode 100644
index 000000000000..09246e13ec5a
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryUptimeTextTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class TuiToolRegistryUptimeTextTest {
+
+    @Test
+    void addsHumanReadableUptimeNextToMillis() {
+        JsonObject context = new JsonObject();
+        context.put("name", "mqtt");
+        context.put("uptime", 13_800_803L);
+
+        TuiToolRegistry.addUptimeText(context);
+
+        assertEquals(13_800_803L, context.get("uptime"), "the millis value 
stays for callers that compute with it");
+        assertEquals("3h50m", context.get("uptimeText"));
+    }
+
+    @Test
+    void leavesTextUptimeAndUnrelatedValuesAlone() {
+        JsonObject route = new JsonObject();
+        route.put("routeId", "route1");
+        route.put("uptime", "3h56m");
+        JsonArray routes = new JsonArray();
+        routes.add(route);
+        JsonObject root = new JsonObject();
+        root.put("routes", routes);
+        JsonObject nested = new JsonObject();
+        nested.put("uptime", 65_000L);
+        root.put("nested", nested);
+
+        TuiToolRegistry.addUptimeText(root);
+
+        assertFalse(route.containsKey("uptimeText"), "route uptime is already 
text");
+        assertEquals("1m5s", nested.get("uptimeText"), "nested objects are 
handled too");
+        TuiToolRegistry.addUptimeText("not json");
+        TuiToolRegistry.addUptimeText(null);
+    }
+}

Reply via email to