@dex-ai/context 0.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -0
- package/package.json +36 -0
- package/src/event-log.ts +246 -0
- package/src/extension.ts +1271 -0
- package/src/formatter.ts +127 -0
- package/src/index.ts +45 -0
- package/src/pressure.ts +61 -0
- package/src/search-tool.ts +230 -0
- package/src/snapshot.ts +240 -0
- package/src/store.ts +678 -0
- package/src/summarize.ts +206 -0
- package/src/tokenizer.ts +20 -0
- package/src/tracker.ts +159 -0
- package/src/types.ts +100 -0
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @dex-ai/context
|
|
2
|
+
|
|
3
|
+
Context management extension for Dex — tracks token usage, provides a visual `/context` command, enforces context budgets, and guides the LLM to avoid flooding the context window.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Context Usage
|
|
9
|
+
|
|
10
|
+
■ ■ ■ ■ ■ ■ ■ ■ ■ ■ Total Usage 102k ( 51.1%)
|
|
11
|
+
■ ■ ■ ■ ■ ■ ■ ■ ■ ■
|
|
12
|
+
■ ■ ■ ■ ■ ■ ■ ■ □ □ ■ System Prompt 1k ( 0.7%)
|
|
13
|
+
□ □ □ □ □ □ □ □ □ □ ■ System Tools 2k ( 0.9%)
|
|
14
|
+
□ □ □ □ □ □ □ □ □ □ ■ Tool Results 96k ( 48.1%)
|
|
15
|
+
■ Messages 3k ( 1.5%)
|
|
16
|
+
□ Available 98k ( 48.9%)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
1. **Real-time context tracking** — estimates token usage across categories (system prompt, tools, messages, tool calls/results, images, files, reasoning) using fast heuristics calibrated against cl100k_base.
|
|
22
|
+
|
|
23
|
+
2. **`/context` command** — visual grid + percentage breakdown showing exactly where context is being consumed, with color-coded categories.
|
|
24
|
+
|
|
25
|
+
3. **Threshold warnings** — automatically injects guidance to the LLM when context usage exceeds configurable thresholds (default: warn at 75%, critical at 90%).
|
|
26
|
+
|
|
27
|
+
4. **Context-awareness skill** — teaches the LLM patterns for keeping outputs small (targeted reads, search-before-read, output truncation).
|
|
28
|
+
|
|
29
|
+
5. **Tool output tracking** — records per-tool output sizes to identify the biggest context consumers.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { contextExtension } from "@dex-ai/context";
|
|
35
|
+
import { Agent } from "@dex-ai/sdk";
|
|
36
|
+
|
|
37
|
+
const agent = await Agent.create({
|
|
38
|
+
provider: "anthropic",
|
|
39
|
+
model: "claude-sonnet-4-20250514",
|
|
40
|
+
extensions: [
|
|
41
|
+
providerExt,
|
|
42
|
+
contextExtension(), // sensible defaults
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### With Options
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
contextExtension({
|
|
51
|
+
maxTokens: 128_000, // override context window (auto-detected from model)
|
|
52
|
+
warnAt: 70, // warn threshold (default: 75%)
|
|
53
|
+
criticalAt: 85, // critical threshold (default: 90%)
|
|
54
|
+
injectGuidance: true, // inject context-awareness skill (default: true)
|
|
55
|
+
trackToolOutputs: true, // track per-tool output sizes (default: true)
|
|
56
|
+
largeOutputThreshold: 4_000, // tokens above which an output is "large"
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## `/context` Command Integration
|
|
61
|
+
|
|
62
|
+
The extension stores accessor functions in `AgentContext.state` that the host (CLI/TUI) can invoke:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// In your host/CLI command handler:
|
|
66
|
+
const getFormatted = agent.context.state.get(
|
|
67
|
+
"context:getFormatted",
|
|
68
|
+
) as () => string;
|
|
69
|
+
const getPlain = agent.context.state.get("context:getPlain") as () => string;
|
|
70
|
+
const getSnapshot = agent.context.state.get(
|
|
71
|
+
"context:getSnapshot",
|
|
72
|
+
) as () => ContextSnapshot;
|
|
73
|
+
|
|
74
|
+
// For TUI rendering (ANSI color codes):
|
|
75
|
+
console.log(getFormatted());
|
|
76
|
+
|
|
77
|
+
// For plain text (no ANSI):
|
|
78
|
+
console.log(getPlain());
|
|
79
|
+
|
|
80
|
+
// For programmatic access:
|
|
81
|
+
const snapshot = getSnapshot();
|
|
82
|
+
console.log(
|
|
83
|
+
`${snapshot.usagePercent}% used, ${snapshot.availableTokens} remaining`,
|
|
84
|
+
);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## How It Works
|
|
88
|
+
|
|
89
|
+
### Token Estimation
|
|
90
|
+
|
|
91
|
+
Uses character-based heuristics calibrated for modern tokenizers:
|
|
92
|
+
|
|
93
|
+
- English text: ~4 chars per token
|
|
94
|
+
- Code: ~3.5 chars per token
|
|
95
|
+
- JSON/structured: ~3 chars per token
|
|
96
|
+
- Images: Anthropic tile-based estimation
|
|
97
|
+
|
|
98
|
+
This is intentionally approximate (±10%). Real token counts come from the provider's `usage` response and are used to calibrate the estimates.
|
|
99
|
+
|
|
100
|
+
### Category Tracking
|
|
101
|
+
|
|
102
|
+
| Category | What's counted |
|
|
103
|
+
| ------------- | ---------------------------------------------------- |
|
|
104
|
+
| System Prompt | System messages + injected skills |
|
|
105
|
+
| System Tools | Tool definitions (name + description + JSON Schema) |
|
|
106
|
+
| Tool Calls | Assistant tool invocations (name + serialized input) |
|
|
107
|
+
| Tool Results | Tool outputs (text, JSON, rich content) |
|
|
108
|
+
| Messages | User + assistant text messages |
|
|
109
|
+
| Images | Image content (resolution-based estimation) |
|
|
110
|
+
| Files | File attachments (size-based estimation) |
|
|
111
|
+
| Reasoning | Chain-of-thought / extended thinking |
|
|
112
|
+
|
|
113
|
+
### Threshold Behavior
|
|
114
|
+
|
|
115
|
+
When context usage exceeds a threshold, the extension injects a concise system message into the next model request:
|
|
116
|
+
|
|
117
|
+
- **Warning (75%)**: `"⚠️ Context usage: 76%. Be concise. Avoid large reads/outputs."`
|
|
118
|
+
- **Critical (90%)**: `"🚨 Context nearly full: 91% used. Complete the current task as concisely as possible."`
|
|
119
|
+
|
|
120
|
+
These are injected once per threshold crossing (not on every model call).
|
|
121
|
+
|
|
122
|
+
### Context-Awareness Skill
|
|
123
|
+
|
|
124
|
+
When `injectGuidance: true` (default), a skill is added to the system prompt teaching the LLM:
|
|
125
|
+
|
|
126
|
+
- Prefer targeted reads (line ranges) over full file reads
|
|
127
|
+
- Use search before read to find relevant sections
|
|
128
|
+
- Truncate bash output with `head`/`tail`/`grep`
|
|
129
|
+
- Avoid redundant re-reads of files already in context
|
|
130
|
+
- Be more concise when context is running low
|
|
131
|
+
|
|
132
|
+
## Comparison with context-mode
|
|
133
|
+
|
|
134
|
+
| Feature | context-mode | @dex-ai/context |
|
|
135
|
+
| ------------------- | ---------------------- | ------------------------------------------------- |
|
|
136
|
+
| Token tracking | ❌ (defers to host) | ✅ Built-in estimation |
|
|
137
|
+
| Visual display | ❌ | ✅ Grid + categories |
|
|
138
|
+
| Hard-blocks tools | ✅ (blocks curl/fetch) | ❌ (guidance-based) |
|
|
139
|
+
| Sandboxed execution | ✅ (separate process) | ❌ (not needed — Dex tools already handle this) |
|
|
140
|
+
| FTS5 knowledge base | ✅ (SQLite) | ❌ (out of scope — use @dex-ai/knowledge) |
|
|
141
|
+
| Session persistence | ✅ (SessionDB) | ❌ (out of scope — use @dex-ai/session-extension) |
|
|
142
|
+
| Native dependencies | better-sqlite3 | None |
|
|
143
|
+
| Weight | 3.6 MB | ~15 KB |
|
|
144
|
+
|
|
145
|
+
**Philosophy difference**: context-mode hard-blocks certain tool calls to prevent context flooding. @dex-ai/context takes a guidance approach — it teaches the LLM good patterns and warns when budgets are exceeded, but trusts the model to make the right choice. This works better with modern models that can follow instructions.
|
|
146
|
+
|
|
147
|
+
## API
|
|
148
|
+
|
|
149
|
+
### `contextExtension(opts?): Extension`
|
|
150
|
+
|
|
151
|
+
Factory function — creates the extension.
|
|
152
|
+
|
|
153
|
+
### `estimateTokens(text: string): number`
|
|
154
|
+
|
|
155
|
+
Estimate token count for a string.
|
|
156
|
+
|
|
157
|
+
### `formatContextUsage(snapshot: ContextSnapshot): string`
|
|
158
|
+
|
|
159
|
+
Format a snapshot with ANSI colors for terminal display.
|
|
160
|
+
|
|
161
|
+
### `formatContextUsagePlain(snapshot: ContextSnapshot): string`
|
|
162
|
+
|
|
163
|
+
Format a snapshot as plain text (no colors).
|
|
164
|
+
|
|
165
|
+
### Types
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
interface ContextSnapshot {
|
|
169
|
+
timestamp: number;
|
|
170
|
+
totalTokens: number;
|
|
171
|
+
maxTokens: number;
|
|
172
|
+
usagePercent: number;
|
|
173
|
+
categories: CategoryUsage[];
|
|
174
|
+
availableTokens: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface CategoryUsage {
|
|
178
|
+
category: ContextCategory;
|
|
179
|
+
tokens: number;
|
|
180
|
+
percent: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type ContextCategory =
|
|
184
|
+
| "system-prompt"
|
|
185
|
+
| "system-tools"
|
|
186
|
+
| "messages"
|
|
187
|
+
| "tool-calls"
|
|
188
|
+
| "tool-results"
|
|
189
|
+
| "images"
|
|
190
|
+
| "files"
|
|
191
|
+
| "reasoning";
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bun install
|
|
198
|
+
bun run typecheck
|
|
199
|
+
bun test
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dex-ai/context",
|
|
3
|
+
"version": "0.7.16",
|
|
4
|
+
"description": "Index-and-pointer context management — indexes all tool outputs into FTS5 knowledge base for zero-loss compression. Provides ctx_search for on-demand retrieval. Zero config.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"changeset": "changeset",
|
|
19
|
+
"version": "changeset version",
|
|
20
|
+
"release": "changeset publish"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@dex-ai/sdk": "^0.1.22",
|
|
24
|
+
"zod": "^3.23.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"typescript": "^5.6.3",
|
|
29
|
+
"@changesets/cli": "^2.29.0"
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/event-log.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event Log — passive extraction of structured events from tool results.
|
|
3
|
+
*
|
|
4
|
+
* This module observes tool results without modifying them. It extracts
|
|
5
|
+
* structured session events (file ops, commands, errors) that are used
|
|
6
|
+
* to build resume snapshots when context pressure requires compression.
|
|
7
|
+
*
|
|
8
|
+
* Zero modification. Zero truncation. Pure observation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { estimateTokens } from "./tokenizer";
|
|
12
|
+
|
|
13
|
+
/* ── Types ─────────────────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
export type EventCategory =
|
|
16
|
+
| "file"
|
|
17
|
+
| "command"
|
|
18
|
+
| "error"
|
|
19
|
+
| "search"
|
|
20
|
+
| "decision";
|
|
21
|
+
|
|
22
|
+
export interface SessionEvent {
|
|
23
|
+
type: string;
|
|
24
|
+
category: EventCategory;
|
|
25
|
+
/** Compact data payload — file path, command, error message */
|
|
26
|
+
data: string;
|
|
27
|
+
/** Unix ms timestamp */
|
|
28
|
+
timestamp: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ── Event Log ─────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
export class EventLog {
|
|
34
|
+
private events: SessionEvent[] = [];
|
|
35
|
+
|
|
36
|
+
append(event: SessionEvent): void {
|
|
37
|
+
this.events.push(event);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
appendAll(events: SessionEvent[]): void {
|
|
41
|
+
this.events.push(...events);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getAll(): ReadonlyArray<SessionEvent> {
|
|
45
|
+
return this.events;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getByCategory(category: EventCategory): SessionEvent[] {
|
|
49
|
+
return this.events.filter((e) => e.category === category);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get length(): number {
|
|
53
|
+
return this.events.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
clear(): void {
|
|
57
|
+
this.events = [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ── Event Extraction ──────────────────────────────────── */
|
|
62
|
+
|
|
63
|
+
export interface ToolResultInput {
|
|
64
|
+
toolName: string;
|
|
65
|
+
input: Record<string, unknown> | undefined;
|
|
66
|
+
outputText: string | null;
|
|
67
|
+
isError: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract structured events from a tool result.
|
|
72
|
+
* Returns 0 or more events depending on the tool type.
|
|
73
|
+
*/
|
|
74
|
+
export function extractEvents(result: ToolResultInput): SessionEvent[] {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const events: SessionEvent[] = [];
|
|
77
|
+
|
|
78
|
+
switch (result.toolName) {
|
|
79
|
+
case "read": {
|
|
80
|
+
const path = str(result.input?.path);
|
|
81
|
+
if (path) {
|
|
82
|
+
events.push({
|
|
83
|
+
type: "file_read",
|
|
84
|
+
category: "file",
|
|
85
|
+
data: path,
|
|
86
|
+
timestamp: now,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case "write": {
|
|
93
|
+
const path = str(result.input?.path);
|
|
94
|
+
if (path) {
|
|
95
|
+
events.push({
|
|
96
|
+
type: "file_write",
|
|
97
|
+
category: "file",
|
|
98
|
+
data: path,
|
|
99
|
+
timestamp: now,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "edit": {
|
|
106
|
+
const path = str(result.input?.path);
|
|
107
|
+
if (path) {
|
|
108
|
+
events.push({
|
|
109
|
+
type: "file_edit",
|
|
110
|
+
category: "file",
|
|
111
|
+
data: path,
|
|
112
|
+
timestamp: now,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "bash": {
|
|
119
|
+
const command = str(result.input?.command);
|
|
120
|
+
if (command) {
|
|
121
|
+
const summary = summarizeBashCommand(command, result.outputText);
|
|
122
|
+
events.push({
|
|
123
|
+
type: "bash_command",
|
|
124
|
+
category: "command",
|
|
125
|
+
data: summary,
|
|
126
|
+
timestamp: now,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Also extract errors
|
|
130
|
+
if (result.isError || detectBashError(result.outputText)) {
|
|
131
|
+
const errorLine = extractErrorLine(result.outputText);
|
|
132
|
+
if (errorLine) {
|
|
133
|
+
events.push({
|
|
134
|
+
type: "error",
|
|
135
|
+
category: "error",
|
|
136
|
+
data: `${truncStr(command, 50)}: ${errorLine}`,
|
|
137
|
+
timestamp: now,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "search": {
|
|
146
|
+
const pattern = str(result.input?.pattern);
|
|
147
|
+
const mode = str(result.input?.mode) || "grep";
|
|
148
|
+
if (pattern) {
|
|
149
|
+
events.push({
|
|
150
|
+
type: "search",
|
|
151
|
+
category: "search",
|
|
152
|
+
data: `${mode} "${truncStr(pattern, 40)}"`,
|
|
153
|
+
timestamp: now,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "lsp_navigation": {
|
|
160
|
+
const op = str(result.input?.operation);
|
|
161
|
+
const file = str(result.input?.filePath);
|
|
162
|
+
if (op && file) {
|
|
163
|
+
events.push({
|
|
164
|
+
type: "lsp",
|
|
165
|
+
category: "search",
|
|
166
|
+
data: `lsp:${op} ${basename(file)}`,
|
|
167
|
+
timestamp: now,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return events;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ── Helpers ───────────────────────────────────────────── */
|
|
178
|
+
|
|
179
|
+
function str(v: unknown): string {
|
|
180
|
+
return typeof v === "string" ? v : "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function truncStr(s: string, max: number): string {
|
|
184
|
+
const cleaned = s.replace(/\s+/g, " ").trim();
|
|
185
|
+
return cleaned.length > max ? cleaned.slice(0, max - 1) + "…" : cleaned;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function basename(path: string): string {
|
|
189
|
+
const parts = path.split("/");
|
|
190
|
+
return parts[parts.length - 1] || path;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function summarizeBashCommand(command: string, output: string | null): string {
|
|
194
|
+
const cmd = truncStr(command, 60);
|
|
195
|
+
|
|
196
|
+
if (!output) return cmd;
|
|
197
|
+
|
|
198
|
+
// Try to extract test results
|
|
199
|
+
const testMatch = output.match(/(\d+)\s*pass(?:ed|ing)?/i);
|
|
200
|
+
const failMatch = output.match(/(\d+)\s*fail(?:ed|ing|ure)?/i);
|
|
201
|
+
if (testMatch || failMatch) {
|
|
202
|
+
const parts: string[] = [];
|
|
203
|
+
if (testMatch) parts.push(`${testMatch[1]} pass`);
|
|
204
|
+
if (failMatch) parts.push(`${failMatch[1]} fail`);
|
|
205
|
+
return `${cmd} → ${parts.join(", ")}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check for publish/deploy output
|
|
209
|
+
const publishMatch = output.match(/\+\s*(@[\w/-]+@[\d.]+)/);
|
|
210
|
+
if (publishMatch) return `${cmd} → ${publishMatch[1]}`;
|
|
211
|
+
|
|
212
|
+
// Exit code
|
|
213
|
+
const exitMatch = output.match(/\[exit code: (\d+)\]/);
|
|
214
|
+
if (exitMatch && exitMatch[1] !== "0") {
|
|
215
|
+
return `${cmd} → exit=${exitMatch[1]}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Short output — include directly
|
|
219
|
+
const lines = output.trim().split("\n");
|
|
220
|
+
if (lines.length <= 2 && output.trim().length < 80) {
|
|
221
|
+
return `${cmd} → ${output.trim()}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return `${cmd} (${lines.length} lines)`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function detectBashError(output: string | null): boolean {
|
|
228
|
+
if (!output) return false;
|
|
229
|
+
// Non-zero exit code is a definitive error signal
|
|
230
|
+
if (/\[exit code: [1-9]\]/.test(output)) return true;
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractErrorLine(output: string | null): string | null {
|
|
235
|
+
if (!output) return null;
|
|
236
|
+
const lines = output.split("\n");
|
|
237
|
+
// Search from end for error patterns
|
|
238
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 20); i--) {
|
|
239
|
+
const line = lines[i]!.trim();
|
|
240
|
+
if (!line) continue;
|
|
241
|
+
if (/error|Error|ERR!|FAIL|panic|exception/i.test(line)) {
|
|
242
|
+
return truncStr(line, 100);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|