@alexeiled/pi-model-router 0.5.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2 — 2026-09-20
4
+
5
+ - Fix context trimming so preserved system instructions count toward the actual token estimate.
6
+ - Display the thinking level used by the completed route, not a pending profile override.
7
+ - Separate classifier, context extraction and pure routing modules; share one runtime state adapter across provider and commands.
8
+ - Validate raw configuration at the boundary and enable strict TypeScript indexing, optional-property and unused-code checks.
9
+ - Add focused Biome async-safety, import-order, cycle and Node import rules.
10
+ - Use Vitest worker threads for the small suite; remove arbitrary test sleeps and obvious test comments while preserving the full assertion set.
11
+ - Align package metadata, architecture documentation and release instructions.
12
+
3
13
  ## 0.5.1 — 2026-09-20
4
14
 
5
15
  - Delegate generation and classification through Pi's native model registry instead of duplicating auth/dispatch logic. Cover keyless and headers-only auth, native providers and credential URLs with in-memory SDK integration tests.
package/README.md CHANGED
@@ -63,13 +63,15 @@ npm run check
63
63
  npm test
64
64
  ```
65
65
 
66
- `npm run check` runs Biome lint, formatting and import-order checks, then the
67
- TypeScript compiler. Warnings fail the check. CI and releases use the same gate.
66
+ `npm run check` runs Biome lint, formatting, import-order and async-safety checks, then
67
+ TypeScript 7 with strict indexing, optional-property and unused-code checks. Warnings fail
68
+ the check. CI and releases use the same gate.
68
69
 
69
70
  - `npm run format` formats TypeScript and root JSON files.
70
71
  - `npm run lint` checks lint rules; `npm run lint:fix` applies safe lint fixes.
71
72
  - `npx biome check --write .` also fixes formatting and import order.
72
73
  - `npm run tsc` runs only the type checker.
74
+ - `npm test` uses Vitest worker threads; this keeps the small suite fast without weakening assertions.
73
75
 
74
76
  [Biome](https://biomejs.dev/) replaces Prettier and supplies linting in one pinned
75
77
  direct tooling dependency, without ESLint or formatter plugins. Type checking stays with
@@ -0,0 +1,93 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+ import type {
3
+ ThinkingLevel as AiThinkingLevel,
4
+ Context,
5
+ } from '@earendil-works/pi-ai';
6
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
7
+ import { isRouterTier, parseCanonicalModelRef } from './config';
8
+ import { extractTextFromContent, getRecentConversationText } from './context';
9
+ import type { RouterPhase, RouterTier } from './types';
10
+
11
+ const CLASSIFIER_TIMEOUT_MS = 10_000;
12
+ const CLASSIFIER_MAX_TOKENS = 256;
13
+
14
+ export const runClassifier = async (
15
+ classifierModelRef: string,
16
+ modelRegistry: ExtensionContext['modelRegistry'],
17
+ context: Context,
18
+ currentPhase?: RouterPhase,
19
+ thinking?: ThinkingLevel,
20
+ signal?: AbortSignal,
21
+ ): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
22
+ if (signal?.aborted) return undefined;
23
+ const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
24
+ if (provider === 'router') return undefined;
25
+ const model = modelRegistry.find(provider, modelId);
26
+ if (!model) return undefined;
27
+
28
+ const latestMessage = context.messages.at(-1);
29
+ const classifierContext: Context = {
30
+ messages: [
31
+ {
32
+ role: 'user',
33
+ content: [
34
+ {
35
+ type: 'text',
36
+ text: [
37
+ 'Classify the coding task into exactly one tier: high, medium, or low.',
38
+ 'Return exactly two lines:',
39
+ 'Tier: <high|medium|low>',
40
+ 'Reasoning: <short reason>',
41
+ `Current phase: ${currentPhase ?? 'unknown'}`,
42
+ `Recent conversation:\n${getRecentConversationText(context)}`,
43
+ `Latest request:\n${latestMessage ? extractTextFromContent(latestMessage.content) : ''}`,
44
+ ].join('\n'),
45
+ },
46
+ ],
47
+ timestamp: Date.now(),
48
+ },
49
+ ],
50
+ };
51
+ const timeout = AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS);
52
+ const classifierSignal = signal
53
+ ? AbortSignal.any([signal, timeout])
54
+ : timeout;
55
+ const reasoning: AiThinkingLevel | undefined =
56
+ thinking && thinking !== 'off' ? thinking : undefined;
57
+ const stream = modelRegistry.streamSimple(model, classifierContext, {
58
+ signal: classifierSignal,
59
+ maxTokens: CLASSIFIER_MAX_TOKENS,
60
+ ...(reasoning ? { reasoning } : {}),
61
+ });
62
+
63
+ let fullText = '';
64
+ let completed = false;
65
+ for await (const event of stream) {
66
+ if (event.type === 'error') return undefined;
67
+ if (event.type === 'text_delta') fullText += event.delta;
68
+ if (event.type === 'done') {
69
+ completed = true;
70
+ fullText = extractTextFromContent(event.message.content);
71
+ break;
72
+ }
73
+ }
74
+ if (!completed) return undefined;
75
+
76
+ const tierLine = fullText
77
+ .split('\n')
78
+ .find((line) => line.toLowerCase().startsWith('tier:'));
79
+ const reasoningLine = fullText
80
+ .split('\n')
81
+ .find((line) => line.toLowerCase().startsWith('reasoning:'));
82
+ if (!tierLine || !reasoningLine) return undefined;
83
+
84
+ const tierValue = tierLine
85
+ .slice(tierLine.indexOf(':') + 1)
86
+ .trim()
87
+ .toLowerCase();
88
+ if (!isRouterTier(tierValue)) return undefined;
89
+ return {
90
+ tier: tierValue,
91
+ reasoning: reasoningLine.slice(reasoningLine.indexOf(':') + 1).trim(),
92
+ };
93
+ };
@@ -6,6 +6,9 @@ import type {
6
6
  import type { AutocompleteItem } from '@earendil-works/pi-tui';
7
7
  import {
8
8
  getUnsupportedTiers,
9
+ isRouterPinValue,
10
+ isRouterTier,
11
+ isThinkingLevel,
9
12
  parseCanonicalModelRef,
10
13
  profileNames,
11
14
  ROUTER_PIN_VALUES,
@@ -110,7 +113,7 @@ export const registerCommands = (
110
113
  args: string[],
111
114
  ): AutocompleteItem[] | null => {
112
115
  // thinking [tier] <level|auto>
113
- const tierValues = [...ROUTER_TIERS];
116
+ const tierValues: RouterTier[] = [...ROUTER_TIERS];
114
117
  const levelValues = ['auto', ...THINKING_LEVELS];
115
118
 
116
119
  if (args.length <= 1) {
@@ -136,8 +139,8 @@ export const registerCommands = (
136
139
  ];
137
140
  }
138
141
 
139
- if ((tierValues as string[]).includes(args[0])) {
140
- const tier = args[0];
142
+ const tier = args[0];
143
+ if (isRouterTier(tier)) {
141
144
  const levelPrefix = args[1] ?? '';
142
145
  return levelValues
143
146
  .filter((v) => v.startsWith(levelPrefix))
@@ -248,7 +251,7 @@ export const registerCommands = (
248
251
 
249
252
  const pinValue = args[0];
250
253
 
251
- if (!ROUTER_PIN_VALUES.some((value) => value === pinValue)) {
254
+ if (!isRouterPinValue(pinValue)) {
252
255
  ctx.ui.notify(
253
256
  `Invalid router pin: ${pinValue}. Use one of: ${ROUTER_PIN_VALUES.join(', ')}`,
254
257
  'error',
@@ -256,7 +259,8 @@ export const registerCommands = (
256
259
  return;
257
260
  }
258
261
 
259
- const nextTier = pinValue === 'auto' ? undefined : (pinValue as RouterTier);
262
+ const nextTier: RouterTier | undefined =
263
+ pinValue === 'auto' ? undefined : pinValue;
260
264
  if (nextTier) {
261
265
  state.pinnedTierByProfile[currentProfile] = nextTier;
262
266
  } else {
@@ -303,16 +307,20 @@ export const registerCommands = (
303
307
  let tier: RouterTier | 'all' | undefined;
304
308
  let levelValue = '';
305
309
 
306
- const tierValues = ['high', 'medium', 'low'];
307
310
  const levelValues = ['auto', ...THINKING_LEVELS];
308
311
 
309
312
  if (args.length === 1) {
310
- levelValue = args[0];
313
+ const level = args[0];
314
+ if (!level) return;
315
+ levelValue = level;
311
316
  tier = 'all';
312
317
  } else if (args.length === 2) {
313
- if (tierValues.includes(args[0]) || args[0] === 'all') {
314
- tier = args[0] as RouterTier | 'all';
315
- levelValue = args[1];
318
+ const requestedTier = args[0];
319
+ const requestedLevel = args[1];
320
+ if (!requestedTier || !requestedLevel) return;
321
+ if (isRouterTier(requestedTier) || requestedTier === 'all') {
322
+ tier = requestedTier === 'all' ? 'all' : requestedTier;
323
+ levelValue = requestedLevel;
316
324
  } else {
317
325
  ctx.ui.notify(
318
326
  `Invalid tier: ${args[0]}. Use high, medium, or low.`,
@@ -322,7 +330,7 @@ export const registerCommands = (
322
330
  }
323
331
  }
324
332
 
325
- if (tier !== 'all' && !tierValues.includes(tier as string)) {
333
+ if (tier !== 'all' && !tier) {
326
334
  ctx.ui.notify(
327
335
  `Invalid tier: ${tier}. Use high, medium, or low.`,
328
336
  'error',
@@ -338,10 +346,17 @@ export const registerCommands = (
338
346
  }
339
347
 
340
348
  const nextLevel =
341
- levelValue === 'auto' ? undefined : (levelValue as ThinkingLevel);
342
- state.thinkingByProfile[currentProfile] ??= {};
343
- const overrides = state.thinkingByProfile[currentProfile];
344
- const tiers = tier === 'all' ? ROUTER_TIERS : [tier as RouterTier];
349
+ levelValue === 'auto'
350
+ ? undefined
351
+ : isThinkingLevel(levelValue)
352
+ ? levelValue
353
+ : undefined;
354
+ let overrides = state.thinkingByProfile[currentProfile];
355
+ if (!overrides) {
356
+ overrides = {};
357
+ state.thinkingByProfile[currentProfile] = overrides;
358
+ }
359
+ const tiers = tier === 'all' ? ROUTER_TIERS : [tier];
345
360
  for (const targetTier of tiers) {
346
361
  if (nextLevel) overrides[targetTier] = nextLevel;
347
362
  else delete overrides[targetTier];
@@ -359,10 +374,9 @@ export const registerCommands = (
359
374
  }
360
375
  // Only warn when the level isn't supported by some tiers; skip for 'off' and 'auto'
361
376
  if (nextLevel && nextLevel !== 'off') {
362
- const unsupported = getUnsupportedTiers(
363
- state.currentConfig.profiles[currentProfile],
364
- nextLevel,
365
- );
377
+ const activeProfile = state.currentConfig.profiles[currentProfile];
378
+ if (!activeProfile) return;
379
+ const unsupported = getUnsupportedTiers(activeProfile, nextLevel);
366
380
  if (unsupported.length > 0) {
367
381
  ctx.ui.notify(
368
382
  `Router thinking (${tier}) set to ${nextLevel}. ` +
@@ -416,7 +430,7 @@ export const registerCommands = (
416
430
  return;
417
431
  }
418
432
  const tier = args[0]?.toLowerCase();
419
- if (!ROUTER_TIERS.includes(tier as RouterTier)) {
433
+ if (!isRouterTier(tier)) {
420
434
  ctx.ui.notify('Usage: /router fix <high|medium|low>', 'error');
421
435
  return;
422
436
  }
@@ -424,7 +438,7 @@ export const registerCommands = (
424
438
  ctx.ui.notify('No recent routing decision to fix.', 'warning');
425
439
  return;
426
440
  }
427
- state.pinnedTierByProfile[state.lastDecision.profile] = tier as RouterTier;
441
+ state.pinnedTierByProfile[state.lastDecision.profile] = tier;
428
442
  actions.persistState();
429
443
  actions.updateStatus(ctx);
430
444
  ctx.ui.notify(
@@ -515,10 +529,12 @@ export const registerCommands = (
515
529
  }
516
530
 
517
531
  if (parts.length === 1 && !hasTrailingSpace) {
518
- return getSubcommandCompletions(parts[0]);
532
+ const subcommand = parts[0];
533
+ return subcommand ? getSubcommandCompletions(subcommand) : null;
519
534
  }
520
535
 
521
536
  const subcommand = parts[0];
537
+ if (!subcommand) return null;
522
538
  const subArgs = parts.slice(1);
523
539
  if (hasTrailingSpace && parts.length === 1) {
524
540
  subArgs.push('');
@@ -597,6 +613,10 @@ export const registerCommands = (
597
613
  const parts = args?.trim().split(/\s+/) ?? [];
598
614
  const subcommand = parts[0];
599
615
  const subArgs = parts.slice(1);
616
+ if (!subcommand) {
617
+ await handleStatus(subArgs, ctx);
618
+ return;
619
+ }
600
620
 
601
621
  switch (subcommand) {
602
622
  case 'profile':
@@ -9,6 +9,7 @@ import type {
9
9
  ConfigLoadResult,
10
10
  ModelDefinition,
11
11
  ParsedConfigFile,
12
+ RawRouterConfig,
12
13
  RoutedTierConfig,
13
14
  RouterConfig,
14
15
  RouterProfile,
@@ -19,7 +20,7 @@ import type {
19
20
  export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
20
21
 
21
22
  // Pi accepts this model capability at runtime, but older peer type releases omit it.
22
- export const MAX_THINKING_LEVEL = 'max' as ThinkingLevel;
23
+ export const MAX_THINKING_LEVEL: ThinkingLevel = 'max';
23
24
 
24
25
  export const THINKING_LEVELS: readonly ThinkingLevel[] = [
25
26
  'off',
@@ -31,6 +32,9 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
31
32
  MAX_THINKING_LEVEL,
32
33
  ];
33
34
  export const ROUTER_PIN_VALUES = ['auto', 'high', 'medium', 'low'] as const;
35
+ export type RouterPinValue = (typeof ROUTER_PIN_VALUES)[number];
36
+ export const isRouterPinValue = (value: unknown): value is RouterPinValue =>
37
+ ROUTER_PIN_VALUES.some((candidate) => candidate === value);
34
38
 
35
39
  export const DEFAULT_THINKING_LEVELS: readonly ThinkingLevel[] = [
36
40
  'high',
@@ -44,7 +48,7 @@ export const isObjectRecord = (
44
48
  typeof value === 'object' && value !== null && !Array.isArray(value);
45
49
 
46
50
  export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
47
- typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
51
+ typeof value === 'string' && THINKING_LEVELS.some((level) => level === value);
48
52
 
49
53
  export const isRouterTier = (value: unknown): value is RouterTier =>
50
54
  value === 'high' || value === 'medium' || value === 'low';
@@ -55,14 +59,14 @@ export const parseConfigFile = (path: string): ParsedConfigFile => {
55
59
  }
56
60
 
57
61
  try {
58
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
62
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'));
59
63
  if (!isObjectRecord(parsed)) {
60
64
  return {
61
65
  config: {},
62
66
  warnings: [`Ignored router config at ${path}: expected a JSON object.`],
63
67
  };
64
68
  }
65
- return { config: parsed as Partial<RouterConfig>, warnings: [] };
69
+ return { config: parsed, warnings: [] };
66
70
  } catch (error) {
67
71
  return {
68
72
  config: {},
@@ -90,40 +94,42 @@ export const resolveModelRef = (
90
94
  return { canonicalRef: ref };
91
95
  };
92
96
 
93
- const mergeTier = (
94
- existing?: RoutedTierConfig,
95
- next?: Partial<RoutedTierConfig>,
96
- ): RoutedTierConfig | undefined => {
97
- if (!existing && !next) return undefined;
98
- if (!next) return existing;
99
- if (!existing) return next as RoutedTierConfig;
100
- return { ...existing, ...next };
97
+ const mergeRawValue = (existing: unknown, next: unknown): unknown => {
98
+ if (next === undefined) return existing;
99
+ if (isObjectRecord(existing) && isObjectRecord(next)) {
100
+ return { ...existing, ...next };
101
+ }
102
+ return next;
101
103
  };
102
104
 
103
105
  export const mergeConfig = (
104
- base: RouterConfig,
105
- override: Partial<RouterConfig>,
106
- ): RouterConfig => {
107
- const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
108
- for (const [name, profile] of Object.entries(
109
- isObjectRecord(override.profiles) ? override.profiles : {},
110
- )) {
111
- if (!isObjectRecord(profile) || name === '__proto__') continue;
112
- const existing = Object.hasOwn(mergedProfiles, name)
106
+ base: RawRouterConfig,
107
+ override: RawRouterConfig,
108
+ ): RawRouterConfig => {
109
+ const baseProfiles = isObjectRecord(base.profiles) ? base.profiles : {};
110
+ const overrideProfiles = isObjectRecord(override.profiles)
111
+ ? override.profiles
112
+ : {};
113
+ const mergedProfiles: Record<string, unknown> = { ...baseProfiles };
114
+ for (const [name, profile] of Object.entries(overrideProfiles)) {
115
+ if (name === '__proto__') continue;
116
+ if (!isObjectRecord(profile)) {
117
+ mergedProfiles[name] = profile;
118
+ continue;
119
+ }
120
+ const existing = isObjectRecord(mergedProfiles[name])
113
121
  ? mergedProfiles[name]
114
- : undefined;
115
- const nextProfile = profile as Partial<RouterProfile>;
122
+ : {};
116
123
  mergedProfiles[name] = {
117
- high: mergeTier(existing?.high, nextProfile.high),
118
- medium: mergeTier(existing?.medium, nextProfile.medium),
119
- low: mergeTier(existing?.low, nextProfile.low),
124
+ high: mergeRawValue(existing.high, profile.high),
125
+ medium: mergeRawValue(existing.medium, profile.medium),
126
+ low: mergeRawValue(existing.low, profile.low),
120
127
  };
121
128
  }
122
129
 
123
- const mergedModels: Record<string, ModelDefinition> = {
124
- ...(base.models ?? {}),
125
- ...(isObjectRecord(override.models) ? override.models : {}),
126
- };
130
+ const baseModels = isObjectRecord(base.models) ? base.models : {};
131
+ const overrideModels = isObjectRecord(override.models) ? override.models : {};
132
+ const mergedModels = { ...baseModels, ...overrideModels };
127
133
 
128
134
  return {
129
135
  debug: override.debug ?? base.debug,
@@ -159,7 +165,7 @@ export const parseCanonicalModelRef = (
159
165
  * Validate and normalize the models map from config.
160
166
  */
161
167
  export const normalizeModelsMap = (
162
- raw: Record<string, unknown> | undefined,
168
+ raw: unknown,
163
169
  warnings: string[],
164
170
  ): Record<string, ModelDefinition> => {
165
171
  const result: Record<string, ModelDefinition> = {};
@@ -321,8 +327,8 @@ export const normalizeTierConfig = (
321
327
  // Validate tier-level thinkingLevels array
322
328
  let tierThinkingLevels: ThinkingLevel[] | undefined;
323
329
  if (Array.isArray(value.thinkingLevels)) {
324
- tierThinkingLevels = (value.thinkingLevels as unknown[]).filter(
325
- (l): l is ThinkingLevel => isThinkingLevel(l),
330
+ tierThinkingLevels = value.thinkingLevels.filter((l): l is ThinkingLevel =>
331
+ isThinkingLevel(l),
326
332
  );
327
333
  if (tierThinkingLevels.length === 0) tierThinkingLevels = undefined;
328
334
  }
@@ -359,14 +365,11 @@ export const normalizeTierConfig = (
359
365
  };
360
366
  };
361
367
 
362
- export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
368
+ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
363
369
  const warnings: string[] = [];
364
370
 
365
371
  // Normalize models map first so aliases are available during tier normalization
366
- const normalizedModels = normalizeModelsMap(
367
- raw.models as Record<string, unknown> | undefined,
368
- warnings,
369
- );
372
+ const normalizedModels = normalizeModelsMap(raw.models, warnings);
370
373
  const hasModels = Object.keys(normalizedModels).length > 0;
371
374
 
372
375
  const normalizedProfiles: Record<string, RouterProfile> = {};
@@ -375,22 +378,23 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
375
378
  isObjectRecord(raw.profiles) ? raw.profiles : {},
376
379
  )) {
377
380
  if (name === '__proto__') continue;
381
+ const profileRecord = isObjectRecord(profile) ? profile : {};
378
382
  const high = normalizeTierConfig(
379
- profile?.high,
383
+ profileRecord.high,
380
384
  name,
381
385
  'high',
382
386
  warnings,
383
387
  hasModels ? normalizedModels : undefined,
384
388
  );
385
389
  const medium = normalizeTierConfig(
386
- profile?.medium,
390
+ profileRecord.medium,
387
391
  name,
388
392
  'medium',
389
393
  warnings,
390
394
  hasModels ? normalizedModels : undefined,
391
395
  );
392
396
  const low = normalizeTierConfig(
393
- profile?.low,
397
+ profileRecord.low,
394
398
  name,
395
399
  'low',
396
400
  warnings,
@@ -446,7 +450,7 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
446
450
 
447
451
  // Resolve classifierModel — accepts string or { model, thinking } object
448
452
  let classifierModel: ClassifierConfig | undefined;
449
- const rawClassifier = raw.classifierModel as unknown;
453
+ const rawClassifier = raw.classifierModel;
450
454
  if (typeof rawClassifier === 'string' && rawClassifier.trim()) {
451
455
  const resolved = resolveModelRef(
452
456
  rawClassifier.trim(),
@@ -510,7 +514,7 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
510
514
  const projectPath = join(cwd, '.pi', 'model-router.json');
511
515
  const globalResult = parseConfigFile(globalPath);
512
516
  const projectResult = parseConfigFile(projectPath);
513
- const baseConfig: RouterConfig = { profiles: {} };
517
+ const baseConfig: RawRouterConfig = { profiles: {} };
514
518
  const merged = mergeConfig(
515
519
  mergeConfig(baseConfig, globalResult.config),
516
520
  projectResult.config,
@@ -649,9 +653,8 @@ export const clampThinkingLevel = (
649
653
 
650
654
  const reqIdx = THINKING_LEVELS.indexOf(requested);
651
655
  for (let i = reqIdx; i >= 0; i--) {
652
- if (supported.includes(THINKING_LEVELS[i])) {
653
- return THINKING_LEVELS[i];
654
- }
656
+ const level = THINKING_LEVELS[i];
657
+ if (level && supported.includes(level)) return level;
655
658
  }
656
659
 
657
660
  return 'off';
@@ -0,0 +1,58 @@
1
+ import type { Context, Message } from '@earendil-works/pi-ai';
2
+
3
+ export const extractTextFromContent = (
4
+ content: string | Message['content'],
5
+ ): string => {
6
+ if (typeof content === 'string') return content;
7
+ return content
8
+ .map((part) => {
9
+ if (part.type === 'text') return part.text;
10
+ if (part.type === 'thinking') return part.thinking;
11
+ if (part.type === 'toolCall') {
12
+ return `${part.name} ${JSON.stringify(part.arguments)}`;
13
+ }
14
+ return '';
15
+ })
16
+ .filter(Boolean)
17
+ .join('\n');
18
+ };
19
+
20
+ export const getLastUserText = (context: Context): string => {
21
+ for (let i = context.messages.length - 1; i >= 0; i -= 1) {
22
+ const message = context.messages[i];
23
+ if (message?.role === 'user') {
24
+ return extractTextFromContent(message.content).trim();
25
+ }
26
+ }
27
+ return '';
28
+ };
29
+
30
+ export const getRecentConversationText = (
31
+ context: Context,
32
+ limit = 6,
33
+ ): string =>
34
+ context.messages
35
+ .slice(-limit)
36
+ .map((message) =>
37
+ message ? extractTextFromContent(message.content).trim() : '',
38
+ )
39
+ .filter(Boolean)
40
+ .join('\n')
41
+ .toLowerCase();
42
+
43
+ export const countToolResults = (context: Context): number =>
44
+ context.messages.filter((message) => message?.role === 'toolResult').length;
45
+
46
+ export const countWords = (text: string): number =>
47
+ text.split(/\s+/).filter(Boolean).length;
48
+
49
+ export const hasImageAttachment = (context: Context): boolean =>
50
+ context.messages.some(
51
+ (message) =>
52
+ message &&
53
+ Array.isArray(message.content) &&
54
+ message.content.some((part) => part.type === 'image'),
55
+ );
56
+
57
+ export const containsAny = (text: string, keywords: string[]): boolean =>
58
+ keywords.some((keyword) => text.includes(keyword));