ei-tui 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,10 +53,10 @@ Priority queue for LLM requests:
53
53
 
54
54
  Multi-provider LLM abstraction layer:
55
55
  - Handles requests to Anthropic, OpenAI, Bedrock, local models
56
- - **Sets `max_tokens: 64000`** for all requests
56
+ - **Sets `max_tokens: 8000`** by default (safe for most providers; users can configure higher per-model)
57
57
  - Prevents unbounded generation (test showed timeout after 2min without limit)
58
58
  - Local models silently clamp to their configured maximums
59
- - Anthropic Opus 4 accepts 64K (200K total context - 64K output = 136K input budget)
59
+ - Anthropic Opus 4 accepts up to 64K output (configure `max_output_tokens` on the model to unlock)
60
60
 
61
61
  **JSON Response Parsing** (`parseJSONResponse()`):
62
62
  - **Strategy 1**: Extract from markdown code blocks (```json)
@@ -23,11 +23,10 @@ export function filterMessagesForContext(
23
23
 
24
24
  const msgMs = new Date(msg.timestamp).getTime();
25
25
 
26
- if (contextBoundary) {
27
- return msgMs >= boundaryMs;
28
- }
26
+ if (msgMs < windowStartMs) return false;
27
+ if (contextBoundary && msgMs < boundaryMs) return false;
29
28
 
30
- return msgMs >= windowStartMs;
29
+ return true;
31
30
  });
32
31
  }
33
32
 
@@ -282,8 +282,7 @@ function normalizeText(text: string): string {
282
282
  .replace(/[\u2018\u2019\u0060\u00B4]/g, "'") // curly single, backtick, acute accent
283
283
  .replace(/[\u2014\u2013\u2012]/g, '-') // em-dash, en-dash, figure dash
284
284
  .replace(/\u00A0/g, ' ') // non-breaking space
285
- .replace(/[\u2000-\u200F]/g, ' ') // unicode space variants
286
- .replace(/\u2026|\.\.\./g, '\u2026'); // normalize both ellipsis forms → unicode ellipsis (1:1)
285
+ .replace(/[\u2000-\u200F]/g, ' '); // unicode space variants
287
286
  }
288
287
 
289
288
  function stripPunctuation(text: string): string {
@@ -297,31 +296,46 @@ function stripPunctuation(text: string): string {
297
296
  .toLowerCase();
298
297
  }
299
298
 
300
- interface WordBoundaryMatch {
299
+ export interface WordBoundaryMatch {
301
300
  start: number;
302
301
  end: number;
303
302
  text: string;
304
303
  }
305
304
 
306
- function findQuoteByWords(quoteText: string, msgText: string): WordBoundaryMatch | null {
305
+ export function expandToWordBoundaries(text: string, start: number, end: number): WordBoundaryMatch {
306
+ // Only walk backward if start is mid-word (not already at a word boundary)
307
+ if (start > 0 && !/\s/.test(text[start]))
308
+ while (start > 0 && !/\s/.test(text[start - 1])) start--;
309
+ // Only walk forward if end is mid-word
310
+ if (end > 0 && !/\s/.test(text[end - 1]))
311
+ while (end < text.length && !/\s/.test(text[end])) end++;
312
+ return { start, end, text: text.slice(start, end) };
313
+ }
314
+
315
+ export function findQuoteByWords(quoteText: string, msgText: string): WordBoundaryMatch | null {
307
316
  const strippedQuote = stripPunctuation(quoteText);
308
317
  const quoteWords = strippedQuote.split(' ').filter(w => w.length > 0);
309
318
 
310
- if (quoteWords.length < 3) return null; // Too short to trust — require at least 3 words
319
+ if (quoteWords.length < 2) return null; // Too short to trust — require at least 2 words
311
320
 
312
- // Build word token list from original message with original positions
321
+ // Build word token list from original message with original positions.
322
+ // Each \S+ token is re-split into sub-tokens (sharing the parent's start/end)
323
+ // so that contractions stripped by stripPunctuation (e.g. don't → "don t")
324
+ // align correctly with quoteWords which is also split on spaces.
313
325
  const wordTokens: Array<{ word: string; start: number; end: number }> = [];
314
326
  const wordRegex = /\S+/g;
315
327
  let match: RegExpExecArray | null;
316
328
  while ((match = wordRegex.exec(msgText)) !== null) {
317
- wordTokens.push({
318
- word: stripPunctuation(match[0]),
319
- start: match.index,
320
- end: match.index + match[0].length,
321
- });
329
+ const tokenStart = match.index;
330
+ const tokenEnd = match.index + match[0].length;
331
+ const stripped = stripPunctuation(match[0]);
332
+ const subWords = stripped.split(' ').filter(w => w.length > 0);
333
+ for (const sub of subWords) {
334
+ wordTokens.push({ word: sub, start: tokenStart, end: tokenEnd });
335
+ }
322
336
  }
323
337
 
324
- // Find contiguous sequence of words matching the quote words
338
+ // Find contiguous sequence of word tokens matching the quote words
325
339
  for (let i = 0; i <= wordTokens.length - quoteWords.length; i++) {
326
340
  let allMatch = true;
327
341
  for (let j = 0; j < quoteWords.length; j++) {
@@ -333,11 +347,7 @@ function findQuoteByWords(quoteText: string, msgText: string): WordBoundaryMatch
333
347
  if (allMatch) {
334
348
  const startToken = wordTokens[i];
335
349
  const endToken = wordTokens[i + quoteWords.length - 1];
336
- return {
337
- start: startToken.start,
338
- end: endToken.end,
339
- text: msgText.slice(startToken.start, endToken.end),
340
- };
350
+ return expandToWordBoundaries(msgText, startToken.start, endToken.end);
341
351
  }
342
352
  }
343
353
 
@@ -370,9 +380,10 @@ async function validateAndStoreQuotes(
370
380
  let matchLevel: string;
371
381
 
372
382
  if (start !== -1) {
373
- matchStart = start;
374
- matchEnd = start + candidate.text.length;
375
- matchText = candidate.text;
383
+ const expanded = expandToWordBoundaries(msgText, start, start + candidate.text.length);
384
+ matchStart = expanded.start;
385
+ matchEnd = expanded.end;
386
+ matchText = expanded.text;
376
387
  matchLevel = "exact";
377
388
  } else {
378
389
  // Level 2: word-boundary fallback
@@ -440,7 +451,8 @@ async function validateAndStoreQuotes(
440
451
  data_item_ids: [dataItemId],
441
452
  persona_groups: [personaGroup || "General"],
442
453
  text: matchText,
443
- speaker: message.role === "human" ? "human" : personaName,
454
+ speaker: message.role === "human" ? "human" : (message.speaker_name ?? personaName),
455
+ channel: personaName,
444
456
  timestamp: message.timestamp,
445
457
  start: matchStart,
446
458
  end: matchEnd,
@@ -1,5 +1,6 @@
1
- import type { ChatMessage, ProviderAccount } from "./types.js";
2
- import { getKnownContextWindow, DEFAULT_TOKEN_LIMIT } from "./model-context-windows.js";
1
+ import type { ChatMessage, ProviderAccount, ModelConfig } from "./types.js";
2
+ const DEFAULT_TOKEN_LIMIT = 8192;
3
+ const DEFAULT_MAX_OUTPUT_TOKENS = 8000;
3
4
 
4
5
  export interface ProviderConfig {
5
6
  baseURL: string;
@@ -9,7 +10,7 @@ export interface ProviderConfig {
9
10
 
10
11
  export interface ResolvedModel {
11
12
  provider: string;
12
- model: string;
13
+ model: string | undefined;
13
14
  config: ProviderConfig;
14
15
  extraHeaders?: Record<string, string>;
15
16
  }
@@ -19,6 +20,8 @@ export interface LLMCallOptions {
19
20
  temperature?: number;
20
21
  /** OpenAI-compatible tools array. When present and non-empty, sent with tool_choice: "auto". */
21
22
  tools?: Record<string, unknown>[];
23
+ /** Fire-and-forget callback invoked after a successful response to increment usage counters. */
24
+ onUsageUpdate?: (modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void;
22
25
  }
23
26
 
24
27
  export interface LLMRawResponse {
@@ -43,27 +46,90 @@ let llmCallCount = 0;
43
46
 
44
47
 
45
48
 
49
+ function isGuid(str: string): boolean {
50
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
51
+ }
52
+
53
+ function buildResolvedModel(account: ProviderAccount, model: ModelConfig): ResolvedModel {
54
+ return {
55
+ provider: account.name,
56
+ model: model.name === "(default)" ? undefined : model.name,
57
+ config: {
58
+ name: account.name,
59
+ baseURL: account.url,
60
+ apiKey: account.api_key || "",
61
+ },
62
+ extraHeaders: account.extra_headers,
63
+ };
64
+ }
65
+
66
+ export function resolveModelById(
67
+ modelId: string,
68
+ accounts: ProviderAccount[]
69
+ ): { account: ProviderAccount; model: ModelConfig } | undefined {
70
+ for (const account of accounts) {
71
+ if (!account.enabled || account.type !== "llm") continue;
72
+ const model = account.models?.find((m) => m.id === modelId);
73
+ if (model) return { account, model };
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ export function getDisplayName(account: ProviderAccount, model: ModelConfig): string {
79
+ return `${account.name}:${model.name}`;
80
+ }
81
+
46
82
  export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]): ResolvedModel {
47
83
  if (!modelSpec) {
48
84
  throw new Error("No model specified. Set a provider on this persona with /provider, or set a default_model in settings.");
49
85
  }
86
+
87
+ if (accounts && isGuid(modelSpec)) {
88
+ const result = resolveModelById(modelSpec, accounts);
89
+ if (result) {
90
+ return buildResolvedModel(result.account, result.model);
91
+ }
92
+
93
+ const fallbackAccount = accounts.find((acc) => acc.enabled && acc.type === "llm" && acc.default_model);
94
+ if (fallbackAccount?.default_model) {
95
+ const fallbackResult = resolveModelById(fallbackAccount.default_model, accounts);
96
+ if (fallbackResult) {
97
+ return buildResolvedModel(fallbackResult.account, fallbackResult.model);
98
+ }
99
+ }
100
+
101
+ throw new Error(
102
+ `Model "${modelSpec}" not found. It may have been deleted. Update this persona's model in settings.`
103
+ );
104
+ }
105
+
50
106
  let provider = "";
51
107
  let model = modelSpec;
52
-
108
+
53
109
  if (modelSpec.includes(":")) {
54
110
  const [p, ...rest] = modelSpec.split(":");
55
111
  provider = p;
56
112
  model = rest.join(":");
57
113
  }
58
- // Try to find matching account by name (case-insensitive)
59
- // Check both "provider:model" format AND bare account names
114
+
60
115
  if (accounts) {
61
- const searchName = provider || modelSpec; // If no ":", the whole spec might be an account name
116
+ const searchName = provider || modelSpec;
62
117
  const matchingAccount = accounts.find(
63
118
  (acc) => acc.name.toLowerCase() === searchName.toLowerCase() && acc.enabled && acc.type === "llm"
64
119
  );
65
120
  if (matchingAccount) {
66
- // If bare account name was used, get model from account's default_model
121
+ const matchingModel = matchingAccount.models?.find((m) => m.name === model);
122
+ if (matchingModel) {
123
+ return buildResolvedModel(matchingAccount, matchingModel);
124
+ }
125
+
126
+ if (!provider && matchingAccount.default_model && matchingAccount.models) {
127
+ const defaultModel = matchingAccount.models.find((m) => m.id === matchingAccount.default_model);
128
+ if (defaultModel) {
129
+ return buildResolvedModel(matchingAccount, defaultModel);
130
+ }
131
+ }
132
+
67
133
  const resolvedModel = provider ? model : (matchingAccount.default_model || model);
68
134
  return {
69
135
  provider: matchingAccount.name,
@@ -77,7 +143,7 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
77
143
  };
78
144
  }
79
145
  }
80
-
146
+
81
147
  throw new Error(
82
148
  `No provider "${provider || modelSpec}" found. Create one with /provider new, or check that it's enabled.`
83
149
  );
@@ -85,44 +151,48 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
85
151
 
86
152
  const tokenLimitLoggedModels = new Set<string>();
87
153
 
154
+ function findModelAndAccount(
155
+ spec: string,
156
+ accounts: ProviderAccount[]
157
+ ): { model: ModelConfig | undefined; account: ProviderAccount | undefined } {
158
+ if (spec.includes(":")) {
159
+ const [providerName, ...rest] = spec.split(":");
160
+ const modelName = rest.join(":");
161
+ const account = accounts.find(
162
+ (a) => a.name.toLowerCase() === providerName.toLowerCase() && a.enabled
163
+ );
164
+ const model = account?.models?.find((m) => m.name === modelName);
165
+ return { model, account };
166
+ }
167
+ for (const account of accounts) {
168
+ const model = account.models?.find((m) => m.id === spec);
169
+ if (model) return { model, account };
170
+ }
171
+ return { model: undefined, account: undefined };
172
+ }
173
+
88
174
  export function resolveTokenLimit(
89
175
  modelSpec?: string,
90
176
  accounts?: ProviderAccount[]
91
177
  ): number {
92
178
  const spec = modelSpec || "";
93
179
 
94
- let provider = "";
95
- let model = spec;
96
- if (spec.includes(":")) {
97
- const [p, ...rest] = spec.split(":");
98
- provider = p;
99
- model = rest.join(":");
100
- }
180
+ if (accounts && spec) {
181
+ const { model, account } = findModelAndAccount(spec, accounts);
101
182
 
102
- // 1. User override on matching account
103
- if (accounts) {
104
- const searchName = provider || spec;
105
- const matchingAccount = accounts.find(
106
- (acc) => acc.name.toLowerCase() === searchName.toLowerCase() && acc.enabled
107
- );
108
- if (matchingAccount?.token_limit) {
109
- logTokenLimit(model, "user-override", matchingAccount.token_limit);
110
- return matchingAccount.token_limit;
183
+ if (model?.token_limit) {
184
+ logTokenLimit(spec, "model-config", model.token_limit);
185
+ return model.token_limit;
111
186
  }
112
- if (matchingAccount && !provider) {
113
- model = matchingAccount.default_model || model;
114
- }
115
- }
116
187
 
117
- // 2. Lookup table
118
- const known = getKnownContextWindow(model);
119
- if (known) {
120
- logTokenLimit(model, "lookup-table", known);
121
- return known;
188
+ if (account?.token_limit) {
189
+ const displayName = spec.includes(":") ? spec.split(":").slice(1).join(":") : spec;
190
+ logTokenLimit(displayName, "user-override", account.token_limit);
191
+ return account.token_limit;
192
+ }
122
193
  }
123
194
 
124
- // 3. Conservative default
125
- logTokenLimit(model, "default", DEFAULT_TOKEN_LIMIT);
195
+ logTokenLimit(spec, "default", DEFAULT_TOKEN_LIMIT);
126
196
  return DEFAULT_TOKEN_LIMIT;
127
197
  }
128
198
 
@@ -148,13 +218,16 @@ export async function callLLMRaw(
148
218
  ): Promise<LLMRawResponse> {
149
219
  llmCallCount++;
150
220
 
151
- const { signal, temperature = 0.7 } = options;
221
+ const { signal, temperature = 0.7, onUsageUpdate } = options;
152
222
 
153
223
  if (signal?.aborted) {
154
224
  throw new Error("LLM call aborted");
155
225
  }
156
226
 
157
227
  const { model, config, extraHeaders } = resolveModel(modelSpec, accounts);
228
+ const { model: modelConfig } = (accounts && modelSpec)
229
+ ? findModelAndAccount(modelSpec, accounts)
230
+ : { model: undefined };
158
231
 
159
232
  const chatMessages: ChatMessage[] = [
160
233
  { role: "system", content: systemPrompt },
@@ -186,10 +259,10 @@ export async function callLLMRaw(
186
259
  }
187
260
 
188
261
  const requestBody: Record<string, unknown> = {
189
- model,
262
+ ...(model !== undefined && { model }),
190
263
  messages: finalMessages,
191
264
  temperature,
192
- max_tokens: 64000, // Opus 4: 128K max output, 200K total context. Local models clamp to their config. Prevents runaway generation.
265
+ max_tokens: modelConfig?.max_output_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
193
266
  };
194
267
 
195
268
  if (options.tools && options.tools.length > 0) {
@@ -210,6 +283,13 @@ export async function callLLMRaw(
210
283
  }
211
284
 
212
285
  const data = await response.json();
286
+
287
+ if (onUsageUpdate && modelConfig) {
288
+ const tokensIn = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
289
+ const tokensOut = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
290
+ onUsageUpdate(modelConfig.id, { calls: 1, tokens_in: tokensIn, tokens_out: tokensOut });
291
+ }
292
+
213
293
  const choice = data.choices?.[0];
214
294
 
215
295
  const assistantMessage = choice?.message as Record<string, unknown> | undefined;
@@ -66,8 +66,6 @@ export interface ExtractionOptions {
66
66
  ceremony_progress?: number;
67
67
  /** Override model for extraction LLM calls */
68
68
  extraction_model?: string;
69
- /** Override token budget for chunking */
70
- extraction_token_limit?: number;
71
69
  /**
72
70
  * Controls whether external (integration-imported) messages are included.
73
71
  * - "exclude": skip messages where external === true
@@ -88,9 +86,6 @@ const EXTRACTION_BUDGET_RATIO = 0.75;
88
86
  const MIN_EXTRACTION_TOKENS = 10000;
89
87
 
90
88
  function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions): number {
91
- if (options?.extraction_token_limit) {
92
- return Math.max(MIN_EXTRACTION_TOKENS, Math.floor(options.extraction_token_limit * EXTRACTION_BUDGET_RATIO));
93
- }
94
89
  const human = state.getHuman();
95
90
  const modelForTokenLimit = options?.extraction_model ?? human.settings?.default_model;
96
91
  const tokenLimit = resolveTokenLimit(modelForTokenLimit, human.settings?.accounts);
@@ -107,6 +107,7 @@ import {
107
107
  getQueueActiveItems,
108
108
  getDLQItems,
109
109
  updateQueueItem,
110
+ deleteQueueItems,
110
111
  clearQueue,
111
112
  submitOneShot,
112
113
  } from "./queue-manager.js";
@@ -1931,6 +1932,10 @@ const toolNextSteps = new Set([
1931
1932
  return updateQueueItem(this.stateManager, id, updates);
1932
1933
  }
1933
1934
 
1935
+ deleteQueueItems(ids: string[]): number {
1936
+ return deleteQueueItems(this.stateManager, ids);
1937
+ }
1938
+
1934
1939
  async clearQueue(): Promise<number> {
1935
1940
  return clearQueue(this.stateManager, this.queueProcessor);
1936
1941
  }
@@ -51,6 +51,10 @@ export function updateQueueItem(
51
51
  return sm.queue_updateItem(id, updates);
52
52
  }
53
53
 
54
+ export function deleteQueueItems(sm: StateManager, ids: string[]): number {
55
+ return sm.queue_deleteItems(ids);
56
+ }
57
+
54
58
  export async function clearQueue(sm: StateManager, qp: QueueProcessor): Promise<number> {
55
59
  qp.abort();
56
60
  return sm.queue_clear();
@@ -542,6 +542,7 @@ export class QueueProcessor {
542
542
  `be parsed as valid JSON. Please reformat it as the JSON object described in your ` +
543
543
  `system instructions. Respond with ONLY the JSON object, or \`{}\` if no changes ` +
544
544
  `are needed.\n\n---\n${malformedContent}\n---` +
545
+ `\n\nThe user does NOT know there was a problem - This request is from Ei to you to try to fix it for them.` +
545
546
  `\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. You are this agent's last hope!`;
546
547
 
547
548
  try {
@@ -158,6 +158,13 @@ export class QueueState {
158
158
  return true;
159
159
  }
160
160
 
161
+ deleteItems(ids: string[]): number {
162
+ const idSet = new Set(ids);
163
+ const before = this.queue.length;
164
+ this.queue = this.queue.filter(r => !idSet.has(r.id));
165
+ return before - this.queue.length;
166
+ }
167
+
161
168
  trimDLQ(): number {
162
169
  const dlqItems = this.queue.filter(r => r.state === "dlq");
163
170
  const cutoff = new Date();