@selesai/code 0.13.11 → 0.13.12

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
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.12] - 2026-09-03
6
+
7
+ ### Added
8
+ - **Auto-continue after output token limit.** When the model stops because it reached the maximum output token limit (`stopReason: "length"`) mid-response, the agent now queues a follow-up "continue from where you left off" message and re-prompts automatically instead of stopping with the "response may be incomplete" error. Truncated tool calls were already re-issued by the agent loop; this covers truncated text. The continuation is capped at 3 consecutive length stops (the counter resets on any normal stop) so a model that keeps hitting the cap doesn't loop forever, and context-pressure length stops still trigger compaction first.
9
+
5
10
  ## [0.13.11] - 2026-09-02
6
11
 
7
12
  ### Added
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { AgentSession } from "./agent-session.js";
3
+ import { SettingsManager } from "./settings-manager.js";
4
+ function createMockSession() {
5
+ const settings = SettingsManager.inMemory({});
6
+ const extensionRunner = {
7
+ getCommand: vi.fn(),
8
+ createCommandContext: vi.fn(),
9
+ emitError: vi.fn(),
10
+ emit: vi.fn(),
11
+ emitMessageEnd: vi.fn(),
12
+ hasHandlers: vi.fn(() => false),
13
+ };
14
+ const resourceLoader = {
15
+ getExtensions: () => ({ extensions: [], errors: [], runtime: { flagValues: new Map(), pendingProviderRegistrations: [], pendingNativeProviderRegistrations: [], assertActive: () => { } } }),
16
+ getSkills: () => ({ skills: [], diagnostics: [] }),
17
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
18
+ getThemes: () => ({ themes: [], diagnostics: [] }),
19
+ getAgents: () => ({ agents: [], diagnostics: [] }),
20
+ getAgentsFiles: () => ({ agentsFiles: [] }),
21
+ getSystemPrompt: () => undefined,
22
+ getAppendSystemPrompt: () => [],
23
+ getExtensionDiagnostics: () => [],
24
+ extendResources: () => { },
25
+ reload: vi.fn(),
26
+ };
27
+ const modelRegistry = {
28
+ registerProvider: vi.fn(),
29
+ unregisterProvider: vi.fn(),
30
+ getProvider: vi.fn(),
31
+ getProviders: vi.fn(() => []),
32
+ getModels: vi.fn(() => []),
33
+ };
34
+ const followUp = vi.fn();
35
+ const hasQueuedMessages = vi.fn(() => false);
36
+ const agent = {
37
+ state: { messages: [], tools: [] },
38
+ subscribe: vi.fn(() => () => { }),
39
+ followUp,
40
+ hasQueuedMessages,
41
+ };
42
+ const session = new AgentSession({
43
+ agent: agent,
44
+ sessionManager: { getBranch: () => [], appendMessage: vi.fn() },
45
+ settingsManager: settings,
46
+ cwd: "/tmp",
47
+ resourceLoader: resourceLoader,
48
+ modelRuntime: modelRegistry,
49
+ });
50
+ session._extensionRunner = extensionRunner;
51
+ return { session, followUp, hasQueuedMessages };
52
+ }
53
+ function lengthStopMessage(text) {
54
+ return {
55
+ role: "assistant",
56
+ content: [{ type: "text", text }],
57
+ stopReason: "length",
58
+ timestamp: Date.now(),
59
+ usage: { input: 100, output: 500, cacheRead: 0, cacheWrite: 0, totalTokens: 600, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
60
+ };
61
+ }
62
+ describe("AgentSession length-stop continuation", () => {
63
+ it("queues a follow-up continuation and returns true for a truncated text response", async () => {
64
+ const { session, followUp } = createMockSession();
65
+ session._lastAssistantMessage = lengthStopMessage("partial response...");
66
+ const result = await session._handlePostAgentRun();
67
+ expect(result).toBe(true);
68
+ expect(followUp).toHaveBeenCalledTimes(1);
69
+ const queued = followUp.mock.calls[0][0];
70
+ expect(queued.role).toBe("user");
71
+ expect(queued.content[0].text).toContain("Continue from where you left off");
72
+ });
73
+ it("does not continue when the truncated message contains tool calls", async () => {
74
+ const { session, followUp } = createMockSession();
75
+ session._lastAssistantMessage = {
76
+ role: "assistant",
77
+ content: [{ type: "toolCall", id: "tc_1", name: "bash", arguments: "{}" }],
78
+ stopReason: "length",
79
+ timestamp: Date.now(),
80
+ };
81
+ const result = await session._handlePostAgentRun();
82
+ expect(result).toBe(false);
83
+ expect(followUp).not.toHaveBeenCalled();
84
+ });
85
+ it("stops after MAX_LENGTH_CONTINUATIONS consecutive length stops", async () => {
86
+ const { session, followUp } = createMockSession();
87
+ session._lengthContinuations = 3;
88
+ session._lastAssistantMessage = lengthStopMessage("still truncated...");
89
+ const result = await session._handlePostAgentRun();
90
+ expect(result).toBe(false);
91
+ expect(followUp).not.toHaveBeenCalled();
92
+ });
93
+ it("resets the continuation counter on a non-length assistant stop", async () => {
94
+ const { session } = createMockSession();
95
+ session._lengthContinuations = 2;
96
+ await session._handleAgentEvent({
97
+ type: "message_end",
98
+ message: {
99
+ role: "assistant",
100
+ content: [{ type: "text", text: "done" }],
101
+ stopReason: "stop",
102
+ timestamp: Date.now(),
103
+ },
104
+ });
105
+ expect(session._lengthContinuations).toBe(0);
106
+ });
107
+ });
@@ -232,6 +232,7 @@ export declare class AgentSession {
232
232
  private _branchSummaryAbortController;
233
233
  private _retryAbortController;
234
234
  private _retryAttempt;
235
+ private _lengthContinuations;
235
236
  private readonly _bashAbortControllers;
236
237
  private _pendingBashMessages;
237
238
  private _extensionRunner;
@@ -82,6 +82,10 @@ function estimateMessagesTokens(messages) {
82
82
  // ============================================================================
83
83
  // Constants
84
84
  // ============================================================================
85
+ /** Max consecutive auto-continuations after a length stop (output token limit). */
86
+ const MAX_LENGTH_CONTINUATIONS = 3;
87
+ /** Follow-up message queued when the model hits the output token limit. */
88
+ const LENGTH_CONTINUATION_MESSAGE = "Your previous response was cut off because it reached the maximum output token limit. Continue from where you left off. Do not repeat content you already produced.";
85
89
  // ============================================================================
86
90
  // AgentSession Class
87
91
  // ============================================================================
@@ -113,6 +117,8 @@ export class AgentSession {
113
117
  // Retry state
114
118
  _retryAbortController = undefined;
115
119
  _retryAttempt = 0;
120
+ // Length-stop continuation state (consecutive output-token-limit continuations)
121
+ _lengthContinuations = 0;
116
122
  // Bash execution state
117
123
  _bashAbortControllers = new Set();
118
124
  _pendingBashMessages = [];
@@ -441,6 +447,11 @@ export class AgentSession {
441
447
  if (assistantMsg.stopReason !== "error") {
442
448
  this._overflowRecoveryAttempted = false;
443
449
  }
450
+ // Reset the length-continuation counter on any non-length stop so the
451
+ // cap only bounds consecutive output-token-limit continuations.
452
+ if (assistantMsg.stopReason !== "length") {
453
+ this._lengthContinuations = 0;
454
+ }
444
455
  // Reset retry counter immediately on successful assistant response
445
456
  // This prevents accumulation across multiple LLM calls within a turn
446
457
  if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
@@ -863,6 +874,18 @@ export class AgentSession {
863
874
  if (await this._checkCompaction(msg)) {
864
875
  return true;
865
876
  }
877
+ // A length stop means the model hit the output token limit mid-response.
878
+ // The agent loop already re-prompts when truncated tool calls are present;
879
+ // here the loop stopped on truncated text, so queue a follow-up to continue.
880
+ // Compaction runs first: a length stop caused by context pressure (input
881
+ // filling the window) should compact instead of blindly continuing.
882
+ if (msg.stopReason === "length" &&
883
+ !msg.content.some((c) => c.type === "toolCall") &&
884
+ this._lengthContinuations < MAX_LENGTH_CONTINUATIONS) {
885
+ this._lengthContinuations++;
886
+ await this._queueFollowUp(LENGTH_CONTINUATION_MESSAGE);
887
+ return true;
888
+ }
866
889
  // The agent loop drains both queues before emitting agent_end. Any messages
867
890
  // here were queued by agent_end extension handlers and need a continuation.
868
891
  return this.agent.hasQueuedMessages();
@@ -24,7 +24,7 @@
24
24
 
25
25
  import * as path from "node:path";
26
26
  import * as fs from "node:fs";
27
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
28
28
  import { MemoryStore } from "./store/memory-store.js";
29
29
  import { SkillStore } from "./store/skill-store.js";
30
30
  import { DatabaseManager } from "./store/db.js";
@@ -106,6 +106,10 @@ export default function (pi: ExtensionAPI) {
106
106
 
107
107
  const shouldMigrateExtensionRoot = !configuredMemoryDir || pointsToLegacyMemoryDir;
108
108
  let persistenceInitialized = false;
109
+ // Captured at session_start: the extension factory runs with no session, but
110
+ // the auto-consolidator needs model access to use the fast in-process direct
111
+ // transport instead of always spawning a slow `pi -p` subprocess agent.
112
+ let sessionModelCtx: Pick<ExtensionContext, "model" | "modelRegistry"> | null = null;
109
113
 
110
114
  const store = new MemoryStore({ ...config, memoryDir: globalDir });
111
115
  // Factory may run with no session (Pi public contract). Do not snapshot
@@ -182,6 +186,7 @@ export default function (pi: ExtensionAPI) {
182
186
 
183
187
  // ── 1. Load memory from disk on session start ──
184
188
  pi.on("session_start", async (_event, ctx) => {
189
+ sessionModelCtx = { model: ctx.model, modelRegistry: ctx.modelRegistry };
185
190
  if (!persistenceInitialized) {
186
191
  try {
187
192
  await measureLifecycle("session-start.persistence-sync", async () => {
@@ -286,6 +291,7 @@ export default function (pi: ExtensionAPI) {
286
291
  config.consolidationTimeoutMs,
287
292
  toolTarget,
288
293
  config,
294
+ sessionModelCtx,
289
295
  );
290
296
  if (result.deferred) {
291
297
  console.info(`⏳ Auto-consolidation for '${toolTarget}' deferred: ${result.error ?? "another session holds the consolidation lock"}`);
@@ -17,7 +17,7 @@ export type FirecrawlOptions = {
17
17
  };
18
18
 
19
19
  export type SearchBackendConfig = {
20
- provider: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
20
+ provider: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'tokenin';
21
21
  baseUrl?: string;
22
22
  fallback?: 'duckduckgo';
23
23
  options?: SearxngOptions;
@@ -54,7 +54,7 @@ export type BackendConfigFile = {
54
54
  };
55
55
 
56
56
  export const DEFAULT_BACKEND_CONFIG: BackendConfig = {
57
- search: { provider: 'duckduckgo' },
57
+ search: { provider: 'tokenin', fallback: 'duckduckgo' },
58
58
  fetch: { provider: 'http' },
59
59
  headless: { provider: 'local-browser' }
60
60
  };
@@ -86,7 +86,7 @@ function extractFirecrawlOptions(value: unknown): FirecrawlOptions | undefined {
86
86
  return Object.keys(options).length > 0 ? options : undefined;
87
87
  }
88
88
 
89
- const PROVIDER_NAMES: SearchProviderName[] = ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily'];
89
+ const PROVIDER_NAMES: SearchProviderName[] = ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily', 'tokenin'];
90
90
 
91
91
  export function usableSearchProviders(
92
92
  search: SearchBackendConfig,
@@ -99,6 +99,7 @@ export function usableSearchProviders(
99
99
  if (env.YDC_API_KEY?.trim()) usable.push('youcom');
100
100
  if (env.EXA_API_KEY?.trim()) usable.push('exa');
101
101
  if (env.TAVILY_API_KEY?.trim()) usable.push('tavily');
102
+ if (hasActiveTokenInAccount()) usable.push('tokenin');
102
103
  return usable;
103
104
  }
104
105
 
@@ -129,7 +130,8 @@ export function extractBackendConfigOverride(
129
130
  backends?.search?.provider === 'brave' ||
130
131
  backends?.search?.provider === 'youcom' ||
131
132
  backends?.search?.provider === 'exa' ||
132
- backends?.search?.provider === 'tavily'
133
+ backends?.search?.provider === 'tavily' ||
134
+ backends?.search?.provider === 'tokenin'
133
135
  ) {
134
136
  override.search = { provider: backends.search.provider };
135
137
  if (backends.search.provider === 'searxng' && typeof backends.search.baseUrl === 'string') {
@@ -186,8 +188,8 @@ export function validateBackendConfig(config: BackendConfig): string[] {
186
188
  issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
187
189
  }
188
190
 
189
- if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng' && config.search.provider !== 'brave' && config.search.provider !== 'youcom' && config.search.provider !== 'exa' && config.search.provider !== 'tavily') {
190
- issues.push('search fallback duckduckgo is only supported when search provider is searxng, brave, youcom, exa, or tavily');
191
+ if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng' && config.search.provider !== 'brave' && config.search.provider !== 'youcom' && config.search.provider !== 'exa' && config.search.provider !== 'tavily' && config.search.provider !== 'tokenin') {
192
+ issues.push('search fallback duckduckgo is only supported when search provider is searxng, brave, youcom, exa, tavily, or tokenin');
191
193
  }
192
194
 
193
195
  if (config.fetch.fallback === 'http' && config.fetch.provider !== 'firecrawl') {
@@ -3,6 +3,7 @@ import { createBraveSearchTool } from '../search/brave.js';
3
3
  import { createYouComSearchTool } from '../search/youcom.js';
4
4
  import { createExaSearchTool } from '../search/exa.js';
5
5
  import { createTavilySearchTool } from '../search/tavily.js';
6
+ import { createTokenInSearchTool } from '../search/tokenin.js';
6
7
  import { createSearxngSearchTool } from '../search/searxng.js';
7
8
  import { createFanoutSearch } from '../search/fanout.js';
8
9
  import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
@@ -31,6 +32,7 @@ export type BackendFactoryDeps = {
31
32
  createYouComSearch?: typeof createYouComSearchTool;
32
33
  createExaSearch?: typeof createExaSearchTool;
33
34
  createTavilySearch?: typeof createTavilySearchTool;
35
+ createTokenInSearch?: typeof createTokenInSearchTool;
34
36
  createHttpFetch?: typeof createWebFetchTool;
35
37
  createFirecrawlFetch?: typeof createFirecrawlFetcher;
36
38
  createHeadlessFetch?: typeof createWebFetchHeadlessTool;
@@ -71,7 +73,7 @@ function invalidFirecrawlFetch() {
71
73
  function withSearchFallback(
72
74
  primary: BackendSet['search'],
73
75
  fallback: BackendSet['search'],
74
- fallbackFrom: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo'
76
+ fallbackFrom: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'tokenin' | 'duckduckgo'
75
77
  ): BackendSet['search'] {
76
78
  return async (input) => {
77
79
  const first = await primary(input);
@@ -124,6 +126,7 @@ export function createBackendSet(
124
126
  const createYouComSearch = deps.createYouComSearch ?? createYouComSearchTool;
125
127
  const createExaSearch = deps.createExaSearch ?? createExaSearchTool;
126
128
  const createTavilySearch = deps.createTavilySearch ?? createTavilySearchTool;
129
+ const createTokenInSearch = deps.createTokenInSearch ?? createTokenInSearchTool;
127
130
  const createHttpFetch = deps.createHttpFetch ?? createWebFetchTool;
128
131
  const createFirecrawlFetch = deps.createFirecrawlFetch ?? createFirecrawlFetcher;
129
132
  const createHeadlessFetch = deps.createHeadlessFetch ?? createWebFetchHeadlessTool;
@@ -142,6 +145,8 @@ export function createBackendSet(
142
145
  return createExaSearch({ apiKey: process.env.EXA_API_KEY });
143
146
  case 'tavily':
144
147
  return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY });
148
+ case 'tokenin':
149
+ return createTokenInSearch();
145
150
  case 'duckduckgo':
146
151
  default:
147
152
  return createDuckDuckGoSearch();
@@ -160,7 +165,9 @@ export function createBackendSet(
160
165
  ? createExaSearch({ apiKey: process.env.EXA_API_KEY })
161
166
  : config.search.provider === 'tavily'
162
167
  ? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY })
163
- : createDuckDuckGoSearch();
168
+ : config.search.provider === 'tokenin'
169
+ ? createTokenInSearch()
170
+ : createDuckDuckGoSearch();
164
171
 
165
172
  if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
166
173
  search = withSearchFallback(search, createDuckDuckGoSearch(), 'searxng');
@@ -182,6 +189,10 @@ export function createBackendSet(
182
189
  search = withSearchFallback(search, createDuckDuckGoSearch(), 'tavily');
183
190
  }
184
191
 
192
+ if (config.search.provider === 'tokenin' && config.search.fallback === 'duckduckgo') {
193
+ search = withSearchFallback(search, createDuckDuckGoSearch(), 'tokenin');
194
+ }
195
+
185
196
  const fanoutConfig = config.search.fanout;
186
197
  if (fanoutConfig && fanoutConfig.mode !== 'off') {
187
198
  const baseNames =
@@ -4,7 +4,28 @@
4
4
  // minimal settings.json read. Ceiling: if selesai moves web search config elsewhere, this
5
5
  // helper needs updating; upgrade path = upstream pi-web-agent adopting a settings hook.
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
- import { getSettingsPath } from '@selesai/code';
7
+ import { join } from 'node:path';
8
+ import { getAgentDir, getSettingsPath } from '@selesai/code';
9
+
10
+ /**
11
+ * True when tokenin-auth.json (written by the tokenin-onboarding extension)
12
+ * has an active account. Used to decide whether the tokenin search provider is
13
+ * usable. Never throws.
14
+ */
15
+ export function hasActiveTokenInAccount(authPath: string = join(getAgentDir(), 'tokenin-auth.json')): boolean {
16
+ try {
17
+ if (!existsSync(authPath)) return false;
18
+ const parsed = JSON.parse(readFileSync(authPath, 'utf-8')) as unknown;
19
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
20
+ const auth = parsed as { accounts?: unknown; activeId?: unknown };
21
+ if (!Array.isArray(auth.accounts) || typeof auth.activeId !== 'string') return false;
22
+ return auth.accounts.some(
23
+ (a) => a && typeof a === 'object' && !Array.isArray(a) && (a as { id?: unknown }).id === auth.activeId
24
+ );
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
8
29
 
9
30
  /**
10
31
  * Read the Brave API key from selesai settings.json (webAgent.braveApiKey).
@@ -278,7 +278,7 @@ function buildBackendSettingsItems(
278
278
  id: 'backend:search:provider',
279
279
  label: 'Search backend',
280
280
  currentValue: backends.search.provider,
281
- values: ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily']
281
+ values: ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily', 'tokenin']
282
282
  },
283
283
  {
284
284
  id: 'backend:search:baseUrl',
@@ -289,8 +289,8 @@ function buildBackendSettingsItems(
289
289
  {
290
290
  id: 'backend:search:fallback',
291
291
  label: 'Search fallback',
292
- currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? backends.search.fallback ?? 'off' : 'off',
293
- values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? ['off', 'duckduckgo'] : ['off']
292
+ currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' || backends.search.provider === 'tokenin' ? backends.search.fallback ?? 'off' : 'off',
293
+ values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' || backends.search.provider === 'tokenin' ? ['off', 'duckduckgo'] : ['off']
294
294
  },
295
295
  {
296
296
  id: 'backend:search:fanout:mode',
@@ -488,7 +488,7 @@ export function applySettingsValue(
488
488
  currentDraft.tools = nextTools;
489
489
  }
490
490
 
491
- if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave' || newValue === 'youcom' || newValue === 'exa' || newValue === 'tavily')) {
491
+ if (id === 'backend:search:provider' && (newValue === 'duckduckgo' || newValue === 'searxng' || newValue === 'brave' || newValue === 'youcom' || newValue === 'exa' || newValue === 'tavily' || newValue === 'tokenin')) {
492
492
  currentBackends.search.provider = newValue;
493
493
  if (newValue !== 'searxng') {
494
494
  delete currentBackends.search.baseUrl;
@@ -500,7 +500,7 @@ export function applySettingsValue(
500
500
  }
501
501
 
502
502
  if (id === 'backend:search:fallback') {
503
- if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave' || currentBackends.search.provider === 'youcom' || currentBackends.search.provider === 'exa' || currentBackends.search.provider === 'tavily')) {
503
+ if (newValue === 'duckduckgo' && (currentBackends.search.provider === 'searxng' || currentBackends.search.provider === 'brave' || currentBackends.search.provider === 'youcom' || currentBackends.search.provider === 'exa' || currentBackends.search.provider === 'tavily' || currentBackends.search.provider === 'tokenin')) {
504
504
  currentBackends.search.fallback = 'duckduckgo';
505
505
  } else {
506
506
  delete currentBackends.search.fallback;
@@ -0,0 +1,130 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getAgentDir } from '@selesai/code';
4
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
5
+ import type { SearchResult, WebSearchResponse } from '../types.js';
6
+
7
+ export const TOKENIN_SEARCH_TOOL_NAME = 'firecrawl';
8
+ export const TOKENIN_DEFAULT_BASE_URL = 'https://lite.andlet.me/v1';
9
+
10
+ type TokenInAccount = { id: string; label: string; apiKey: string; baseUrl?: string };
11
+ type TokenInAuth = { accounts: TokenInAccount[]; activeId: string | null };
12
+
13
+ type LiteLLMSearchResult = { title?: unknown; url?: unknown; snippet?: unknown };
14
+ type LiteLLMSearchResponse = { results?: LiteLLMSearchResult[] };
15
+
16
+ /**
17
+ * Read the active Token-In account from tokenin-auth.json (written by the
18
+ * tokenin-onboarding extension). Returns undefined when no account is saved.
19
+ * Never throws.
20
+ */
21
+ export function readActiveTokenInAccount(
22
+ authPath: string = join(getAgentDir(), 'tokenin-auth.json')
23
+ ): TokenInAccount | undefined {
24
+ try {
25
+ if (!existsSync(authPath)) return undefined;
26
+ const parsed = JSON.parse(readFileSync(authPath, 'utf-8')) as unknown;
27
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
28
+ const auth = parsed as TokenInAuth;
29
+ if (!Array.isArray(auth.accounts) || typeof auth.activeId !== 'string') return undefined;
30
+ return auth.accounts.find((a) => a && typeof a === 'object' && a.id === auth.activeId && typeof a.apiKey === 'string');
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ function resultWithPresentation(result: WebSearchResponse): WebSearchResponse {
37
+ return { ...result, presentation: buildSearchPresentation(result) };
38
+ }
39
+
40
+ function normalizeResults(response: LiteLLMSearchResponse): SearchResult[] {
41
+ return (response.results ?? []).flatMap((item) => {
42
+ if (typeof item.title !== 'string' || typeof item.url !== 'string') return [];
43
+ return [
44
+ {
45
+ title: item.title,
46
+ url: item.url,
47
+ snippet: typeof item.snippet === 'string' ? item.snippet : ''
48
+ }
49
+ ];
50
+ });
51
+ }
52
+
53
+ export function createTokenInSearchTool({
54
+ readAccount = readActiveTokenInAccount,
55
+ fetchImpl = fetch
56
+ }: {
57
+ readAccount?: () => TokenInAccount | undefined;
58
+ fetchImpl?: typeof fetch;
59
+ } = {}) {
60
+ return async function tokenInSearch({ query }: { query: string }): Promise<WebSearchResponse> {
61
+ const normalizedQuery = query.trim();
62
+
63
+ if (!normalizedQuery) {
64
+ return resultWithPresentation({
65
+ status: 'error',
66
+ results: [],
67
+ metadata: { backend: 'tokenin', cacheHit: false },
68
+ error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
69
+ });
70
+ }
71
+
72
+ const account = readAccount();
73
+ if (!account) {
74
+ return resultWithPresentation({
75
+ status: 'error',
76
+ results: [],
77
+ metadata: { backend: 'tokenin', cacheHit: false },
78
+ error: {
79
+ code: 'BACKEND_CONFIG_INVALID',
80
+ message: 'Token-In search requires an active Token-In account. Run /tokenin add first.'
81
+ }
82
+ });
83
+ }
84
+
85
+ const baseUrl = (account.baseUrl ?? TOKENIN_DEFAULT_BASE_URL).replace(/\/+$/, '');
86
+ const url = `${baseUrl}/v1/search/${TOKENIN_SEARCH_TOOL_NAME}`;
87
+
88
+ try {
89
+ const response = await fetchImpl(url, {
90
+ method: 'POST',
91
+ headers: {
92
+ Accept: 'application/json',
93
+ 'Content-Type': 'application/json',
94
+ Authorization: `Bearer ${account.apiKey}`
95
+ },
96
+ body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
97
+ });
98
+
99
+ if (!response.ok) {
100
+ throw new Error(`HTTP ${response.status}`);
101
+ }
102
+
103
+ const parsed = (await response.json()) as LiteLLMSearchResponse;
104
+ const results = normalizeResults(parsed);
105
+
106
+ if (results.length === 0) {
107
+ return resultWithPresentation({
108
+ status: 'error',
109
+ results: [],
110
+ metadata: { backend: 'tokenin', cacheHit: false },
111
+ error: { code: 'NO_RESULTS', message: 'Token-In search returned no usable results for this query.' }
112
+ });
113
+ }
114
+
115
+ return resultWithPresentation({
116
+ status: 'ok',
117
+ results,
118
+ metadata: { backend: 'tokenin', cacheHit: false }
119
+ });
120
+ } catch (error) {
121
+ const rawMessage = error instanceof Error ? error.message : String(error);
122
+ return resultWithPresentation({
123
+ status: 'error',
124
+ results: [],
125
+ metadata: { backend: 'tokenin', cacheHit: false },
126
+ error: { code: 'FETCH_FAILED', message: `Token-In search request failed: ${rawMessage}` }
127
+ });
128
+ }
129
+ };
130
+ }
@@ -16,7 +16,7 @@ export type SearchResult = {
16
16
  snippet: string;
17
17
  };
18
18
 
19
- export type SearchProviderName = 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
19
+ export type SearchProviderName = 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'tokenin';
20
20
 
21
21
  export type FanoutMode = 'off' | 'on' | 'auto';
22
22
 
@@ -32,9 +32,9 @@ export type ToolError = {
32
32
  };
33
33
 
34
34
  export type SearchMetadata = {
35
- backend: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
35
+ backend: SearchProviderName;
36
36
  cacheHit: boolean;
37
- fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo';
37
+ fallbackFrom?: Exclude<SearchProviderName, 'tokenin'>;
38
38
  fallbackReason?: string;
39
39
  fanout?: FanoutMetadata;
40
40
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.11",
3
+ "version": "0.13.12",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "repository": {