@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 +5 -0
- package/dist/core/agent-session-length-continuation.test.d.ts +1 -0
- package/dist/core/agent-session-length-continuation.test.js +107 -0
- package/dist/core/agent-session.d.ts +1 -0
- package/dist/core/agent-session.js +23 -0
- package/dist/extensions/pi-hermes-memory/src/index.ts +7 -1
- package/dist/extensions/pi-web-agent/src/backends/config.ts +8 -6
- package/dist/extensions/pi-web-agent/src/backends/factory.ts +13 -2
- package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +22 -1
- package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +5 -5
- package/dist/extensions/pi-web-agent/src/search/tokenin.ts +130 -0
- package/dist/extensions/pi-web-agent/src/types.ts +3 -3
- package/package.json +1 -1
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
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
|
-
:
|
|
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 {
|
|
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:
|
|
35
|
+
backend: SearchProviderName;
|
|
36
36
|
cacheHit: boolean;
|
|
37
|
-
fallbackFrom?:
|
|
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.
|
|
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": {
|