pi-openai-toolkit 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -31
- package/README_zh.md +2 -31
- package/package.json +3 -12
- package/src/config.ts +1 -51
- package/src/types.ts +0 -19
- package/tsconfig.check.json +1 -8
- package/extensions/reasoning-translation.ts +0 -2
- package/src/reasoning-translation/client.ts +0 -187
- package/src/reasoning-translation/extension.ts +0 -105
- package/src/reasoning-translation/persistence.ts +0 -73
- package/src/reasoning-translation/segmenter.ts +0 -60
- package/src/reasoning-translation/state.ts +0 -399
- package/src/reasoning-translation/types.ts +0 -64
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# pi-openai-toolkit
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>OpenAI-focused toolkit for <a href="https://github.com/badlogic/pi">Pi</a>: Native Remote Compaction v2
|
|
4
|
+
<strong>OpenAI-focused toolkit for <a href="https://github.com/badlogic/pi">Pi</a>: Native Remote Compaction v2 & Hosted Web Search</strong>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
`pi-openai-toolkit` is an all-in-one OpenAI enhancement package for **Pi (>= 0.84.x)**. It bundles
|
|
13
|
+
`pi-openai-toolkit` is an all-in-one OpenAI enhancement package for **Pi (>= 0.84.x)**. It bundles two high-performance extensions designed for OpenAI Responses endpoints:
|
|
14
14
|
|
|
15
15
|
1. **Remote Responses Compaction v2** (`extensions/compaction.ts`): Server-side opaque context compaction using the `remote_compaction_v2` SSE streaming protocol. Preserves context fidelity without lossy text re-summarization.
|
|
16
16
|
2. **Model-Scoped Web Search** (`extensions/web-search.ts`): Toolkit-owned hosted Web Search for an exact `provider/model-id` allowlist configured with `webSearch.models`, across the supported `openai-responses` and `openai-codex-responses` APIs.
|
|
17
|
-
3. **Real-time Reasoning Translation** (`extensions/reasoning-translation.ts`): TUI-only, display-layer translation of streaming thinking/reasoning segments through a separately configured model. It never changes the assistant message or the model context.
|
|
18
17
|
|
|
19
18
|
## Installation
|
|
20
19
|
|
|
@@ -58,9 +57,6 @@ pi --no-extensions -e /absolute/path/to/pi-openai-toolkit/extensions/compaction.
|
|
|
58
57
|
|
|
59
58
|
# Web Search only
|
|
60
59
|
pi --no-extensions -e /absolute/path/to/pi-openai-toolkit/extensions/web-search.ts
|
|
61
|
-
|
|
62
|
-
# Reasoning Translation only
|
|
63
|
-
pi --no-extensions -e /absolute/path/to/pi-openai-toolkit/extensions/reasoning-translation.ts
|
|
64
60
|
```
|
|
65
61
|
|
|
66
62
|
> ⚠️ **Conflict Warning**: Do not run `pi-remote-compact`, `@lll9p/pi-better-compaction`, or standalone `pi-openai-web-search` alongside `pi-openai-toolkit`. Duplicate hooks cause race conditions and duplicated system prompts.
|
|
@@ -107,14 +103,6 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
107
103
|
"models": [
|
|
108
104
|
"uwoacrimson/gpt-5.6-luna"
|
|
109
105
|
]
|
|
110
|
-
},
|
|
111
|
-
"reasoningTranslation": {
|
|
112
|
-
"enabled": true,
|
|
113
|
-
"models": [
|
|
114
|
-
"uwoacrimson/gpt-5.6-luna"
|
|
115
|
-
],
|
|
116
|
-
"model": "openai/gpt-4o-mini",
|
|
117
|
-
"targetLanguage": "Simplified Chinese"
|
|
118
106
|
}
|
|
119
107
|
}
|
|
120
108
|
```
|
|
@@ -140,19 +128,6 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
140
128
|
- `enabled` (*boolean*, default: `true`): Enable or disable native OpenAI Web Search injection.
|
|
141
129
|
- `models` (*string[]*): Exact `provider/model-id` allowlist. Toolkit Web Search is used only for listed models running on `openai-responses` or `openai-codex-responses`. An empty list disables toolkit Web Search for all models.
|
|
142
130
|
|
|
143
|
-
#### `reasoningTranslation`
|
|
144
|
-
|
|
145
|
-
- `enabled` (*boolean*, default: `true`): Enable display-only reasoning translation when the other fields also match.
|
|
146
|
-
- `models` (*string[]*): Exact source-model allowlist. The key must be `provider/model-id`; an empty list disables translation.
|
|
147
|
-
- `model` (*string | null*, default: `null`): Separate `provider/model-id` used for translation requests. It is never selected as Pi's active model and is called with thinking/reasoning disabled.
|
|
148
|
-
- `targetLanguage` (*string*, default: `"Simplified Chinese"`): Natural-language name passed to the translator prompt.
|
|
149
|
-
|
|
150
|
-
Reasoning Translation runs only in Pi's interactive **TUI** mode and only while the thinking area is visible. It listens to streaming `thinking_delta` events, groups text at sentence/newline boundaries, cuts unbroken text at about 400 UTF-16 characters, and flushes an idle tail after about 800 ms or at `thinking_end`. Requests run one at a time in FIFO order, so a slow segment cannot reorder the display.
|
|
151
|
-
|
|
152
|
-
The Markdown transformer replaces only what the TUI renders. Original assistant thinking remains in the session and in every later provider/context payload; no custom message or tool call is sent to the main model. Completed display projections are stored as invisible custom session entries so resumed sessions can restore the same presentation. RPC, JSON, and print modes never call the translator and preserve Pi's original stream output.
|
|
153
|
-
|
|
154
|
-
Translation adds model latency and usage cost. If the translator is missing, unavailable, rate-limited, times out, returns empty output, or fails for one segment, the main response continues; that segment falls back to its exact source text and later segments continue. Aborting, switching models/sessions, reloading, or quitting cancels the active queue and ignores late results.
|
|
155
|
-
|
|
156
131
|
---
|
|
157
132
|
|
|
158
133
|
## Core Mechanics
|
|
@@ -194,10 +169,6 @@ For allowlisted models using `openai-responses` or `openai-codex-responses`, the
|
|
|
194
169
|
- **Zero-Latency**: Uses Pi's existing connection, auth headers, and base URL without standalone HTTP overhead.
|
|
195
170
|
- **Toolkit Ownership**: For an eligible model, the outgoing payload removes the local function tool named `web_search` so native search is the only search path. Switching to an ineligible model restores that local tool's previous active state.
|
|
196
171
|
|
|
197
|
-
### 3. Real-time Reasoning Translation
|
|
198
|
-
|
|
199
|
-
The translation extension is intentionally independent from compaction and Web Search. It registers lifecycle listeners and one synchronous Markdown transformer, but no Function Tool and no provider-request rewrite. A nested `streamSimple()` call is made directly on the configured translator provider, which avoids switching the active Pi model and avoids recursive toolkit hooks. The TUI invalidation bridge refreshes the existing thinking container after each completed segment, preserving Pi's color, italic, and folding behavior.
|
|
200
|
-
|
|
201
172
|
---
|
|
202
173
|
|
|
203
174
|
## Development & Testing
|
package/README_zh.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# pi-openai-toolkit
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>专为 <a href="https://github.com/badlogic/pi">Pi</a> 打造的 OpenAI 全功能增强工具包:远端无损压缩 v2
|
|
4
|
+
<strong>专为 <a href="https://github.com/badlogic/pi">Pi</a> 打造的 OpenAI 全功能增强工具包:远端无损压缩 v2 & 原生托管联网搜索</strong>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
`pi-openai-toolkit` 是专为 **Pi (>= 0.84.x)** 打造的 OpenAI
|
|
13
|
+
`pi-openai-toolkit` 是专为 **Pi (>= 0.84.x)** 打造的 OpenAI 全功能增强工具包,集成了两项针对 OpenAI Responses 协议的核心扩展:
|
|
14
14
|
|
|
15
15
|
1. **远端无损压缩 (Remote Compaction v2)** (`extensions/compaction.ts`): 基于 `remote_compaction_v2` SSE 流式协议实现的服务端密文无损会话压缩,彻底告别会话变长后丢失细节的文本二次摘要。
|
|
16
16
|
2. **按模型启用的联网搜索** (`extensions/web-search.ts`): 通过 `webSearch.models` 精确匹配 `provider/model-id`,由 Toolkit 为符合条件的 `openai-responses` 与 `openai-codex-responses` 会话提供并拥有原生联网搜索。
|
|
17
|
-
3. **实时思考翻译** (`extensions/reasoning-translation.ts`): 仅在 TUI 展示层把流式 thinking/reasoning 分段翻译为目标语言。它使用独立配置的翻译模型,不修改 assistant 消息、会话原文或主模型上下文。
|
|
18
17
|
|
|
19
18
|
## 安装使用
|
|
20
19
|
|
|
@@ -58,9 +57,6 @@ pi --no-extensions -e /本地绝对路径/pi-openai-toolkit/extensions/compactio
|
|
|
58
57
|
|
|
59
58
|
# 仅加载原生联网搜索
|
|
60
59
|
pi --no-extensions -e /本地绝对路径/pi-openai-toolkit/extensions/web-search.ts
|
|
61
|
-
|
|
62
|
-
# 仅加载实时思考翻译
|
|
63
|
-
pi --no-extensions -e /本地绝对路径/pi-openai-toolkit/extensions/reasoning-translation.ts
|
|
64
60
|
```
|
|
65
61
|
|
|
66
62
|
> ⚠️ **冲突提示**:请勿同时启用 `pi-remote-compact`、`@lll9p/pi-better-compaction` 或独立的 `pi-openai-web-search`。多重钩子会产生竞态或注入重复的 System Prompt。
|
|
@@ -107,14 +103,6 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
107
103
|
"models": [
|
|
108
104
|
"uwoacrimson/gpt-5.6-luna"
|
|
109
105
|
]
|
|
110
|
-
},
|
|
111
|
-
"reasoningTranslation": {
|
|
112
|
-
"enabled": true,
|
|
113
|
-
"models": [
|
|
114
|
-
"uwoacrimson/gpt-5.6-luna"
|
|
115
|
-
],
|
|
116
|
-
"model": "openai/gpt-4o-mini",
|
|
117
|
-
"targetLanguage": "Simplified Chinese"
|
|
118
106
|
}
|
|
119
107
|
}
|
|
120
108
|
```
|
|
@@ -140,19 +128,6 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
140
128
|
- `enabled` (*boolean*, 默认 `true`): 是否启用原生 OpenAI 联网搜索注入。
|
|
141
129
|
- `models` (*string[]*): 精确的 `provider/model-id` 模型白名单。只有列表中的模型在 `openai-responses` 或 `openai-codex-responses` 端点上运行时,才会使用 Toolkit Web Search。空列表表示所有模型都不启用 Toolkit Web Search。
|
|
142
130
|
|
|
143
|
-
#### `reasoningTranslation`(实时思考翻译配置)
|
|
144
|
-
|
|
145
|
-
- `enabled` (*boolean*, 默认 `true`): 是否启用展示层思考翻译。
|
|
146
|
-
- `models` (*string[]*): 主模型的精确 `provider/model-id` 白名单。空列表表示不翻译任何模型。
|
|
147
|
-
- `model` (*string | null*, 默认 `null`): 独立的翻译模型,格式为 `provider/model-id`。它不会被设置为 Pi 当前模型,翻译请求也不会启用 thinking/reasoning。
|
|
148
|
-
- `targetLanguage` (*string*, 默认 `"Simplified Chinese"`): 传给翻译模型的目标语言名称,可以改为其他自然语言。
|
|
149
|
-
|
|
150
|
-
实时思考翻译只在 Pi 交互式 **TUI** 模式运行,并且要求 Pi 没有全局隐藏 thinking 区域。扩展监听流式 `thinking_delta`,优先在句号、问号、分号或换行处分段;没有自然边界时约每 400 个 UTF-16 字符切分,空闲约 800 ms 或收到 `thinking_end` 时刷新尾部。翻译请求严格按 FIFO 单并发执行,不会因为响应速度不同而乱序。
|
|
151
|
-
|
|
152
|
-
替换只发生在 TUI 的 Markdown 展示层。session 中的原始 assistant thinking、后续 Provider payload、LLM 上下文和压缩输入都保持不变;扩展不会注册 Function Tool,也不会向主模型发送 custom message。已完成的展示映射会作为不可见的 custom entry 保存,恢复 session 或切换会话树后可以继续替换对应原文。RPC、JSON 与 print 模式不会调用翻译模型,也不会改变 Pi 原始输出。
|
|
153
|
-
|
|
154
|
-
翻译会增加额外延迟和模型费用。翻译模型未配置、认证失败、模型不存在、超时、限流、空输出或某个分段失败时,主模型响应仍会继续;失败分段精确回退为原文,后续分段继续处理。中止、切换模型/会话、扩展重载或退出时会取消当前队列并忽略迟到结果。
|
|
155
|
-
|
|
156
131
|
---
|
|
157
132
|
|
|
158
133
|
## 核心工作原理解析
|
|
@@ -194,10 +169,6 @@ Content-Type: application/json
|
|
|
194
169
|
- **零额外网络延迟**:复用 Pi 当前活跃的 Provider 配置、认证凭据与 Base URL;
|
|
195
170
|
- **Toolkit 接管搜索**:对于符合条件的模型,发给 Provider 的载荷会移除名为 `web_search` 的本地 Function Tool,只保留原生搜索;切换到不符合条件的模型后,会恢复该本地工具原先的 active 状态。
|
|
196
171
|
|
|
197
|
-
### 3. 实时思考翻译
|
|
198
|
-
|
|
199
|
-
该扩展与压缩、联网搜索彼此独立:只注册生命周期监听器和一个同步 Markdown transformer,不注册 Function Tool,也不改写主模型的 Provider request。翻译直接调用配置模型所属 Provider 的 `streamSimple()`,因此不会切换当前模型,也不会递归触发 Toolkit 的其他请求钩子。每个分段完成后通过 Pi 公共的 `setHiddenThinkingLabel()` 触发展示刷新,继续使用 Pi 原有 thinking 容器的颜色、斜体和折叠行为。
|
|
200
|
-
|
|
201
172
|
---
|
|
202
173
|
|
|
203
174
|
## 测试与验证
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openai-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "OpenAI-focused Pi toolkit providing remote Responses compaction
|
|
6
|
+
"description": "OpenAI-focused Pi toolkit providing remote Responses compaction and native Web Search.",
|
|
7
7
|
"author": "awoaCrim and contributors",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -24,21 +24,18 @@
|
|
|
24
24
|
"openai",
|
|
25
25
|
"compaction",
|
|
26
26
|
"web-search",
|
|
27
|
-
"reasoning-translation",
|
|
28
27
|
"codex",
|
|
29
28
|
"responses"
|
|
30
29
|
],
|
|
31
30
|
"pi": {
|
|
32
31
|
"extensions": [
|
|
33
32
|
"./extensions/compaction.ts",
|
|
34
|
-
"./extensions/web-search.ts"
|
|
35
|
-
"./extensions/reasoning-translation.ts"
|
|
33
|
+
"./extensions/web-search.ts"
|
|
36
34
|
]
|
|
37
35
|
},
|
|
38
36
|
"files": [
|
|
39
37
|
"extensions/compaction.ts",
|
|
40
38
|
"extensions/web-search.ts",
|
|
41
|
-
"extensions/reasoning-translation.ts",
|
|
42
39
|
"src/compact-client.ts",
|
|
43
40
|
"src/config.ts",
|
|
44
41
|
"src/debug.ts",
|
|
@@ -57,12 +54,6 @@
|
|
|
57
54
|
"src/web-search/payload.ts",
|
|
58
55
|
"src/web-search/prompt.ts",
|
|
59
56
|
"src/web-search/types.ts",
|
|
60
|
-
"src/reasoning-translation/client.ts",
|
|
61
|
-
"src/reasoning-translation/extension.ts",
|
|
62
|
-
"src/reasoning-translation/persistence.ts",
|
|
63
|
-
"src/reasoning-translation/segmenter.ts",
|
|
64
|
-
"src/reasoning-translation/state.ts",
|
|
65
|
-
"src/reasoning-translation/types.ts",
|
|
66
57
|
"README.md",
|
|
67
58
|
"README_zh.md",
|
|
68
59
|
"LICENSE",
|
package/src/config.ts
CHANGED
|
@@ -2,10 +2,8 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
|
-
import { parseModelSpec } from "./runtime";
|
|
6
5
|
import {
|
|
7
6
|
DEFAULT_COMPACTION_CONFIG,
|
|
8
|
-
DEFAULT_REASONING_TRANSLATION_CONFIG,
|
|
9
7
|
DEFAULT_TOOLKIT_CONFIG,
|
|
10
8
|
DEFAULT_WEB_SEARCH_CONFIG,
|
|
11
9
|
RESPONSES_COMPACT_CAPABLE_APIS,
|
|
@@ -13,7 +11,6 @@ import {
|
|
|
13
11
|
TOOLKIT_ID,
|
|
14
12
|
type CompactionConfig,
|
|
15
13
|
type LoadedToolkitConfig,
|
|
16
|
-
type ReasoningTranslationConfig,
|
|
17
14
|
type ToolkitConfig,
|
|
18
15
|
type WebSearchConfig,
|
|
19
16
|
} from "./types";
|
|
@@ -21,7 +18,7 @@ import {
|
|
|
21
18
|
export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
|
|
22
19
|
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
23
20
|
|
|
24
|
-
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch"
|
|
21
|
+
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch"]);
|
|
25
22
|
const COMPACTION_FIELDS = new Set([
|
|
26
23
|
"enabled",
|
|
27
24
|
"allowCompactionContinuityBreak",
|
|
@@ -37,7 +34,6 @@ const COMPACTION_FIELDS = new Set([
|
|
|
37
34
|
"artifactRoot",
|
|
38
35
|
]);
|
|
39
36
|
const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
|
|
40
|
-
const REASONING_TRANSLATION_FIELDS = new Set(["enabled", "models", "model", "targetLanguage"]);
|
|
41
37
|
|
|
42
38
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
39
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -162,10 +158,6 @@ function cloneDefaults(): ToolkitConfig {
|
|
|
162
158
|
...DEFAULT_WEB_SEARCH_CONFIG,
|
|
163
159
|
models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
|
|
164
160
|
},
|
|
165
|
-
reasoningTranslation: {
|
|
166
|
-
...DEFAULT_REASONING_TRANSLATION_CONFIG,
|
|
167
|
-
models: [...DEFAULT_REASONING_TRANSLATION_CONFIG.models],
|
|
168
|
-
},
|
|
169
161
|
};
|
|
170
162
|
}
|
|
171
163
|
|
|
@@ -244,40 +236,6 @@ function applyWebSearchConfig(
|
|
|
244
236
|
}
|
|
245
237
|
}
|
|
246
238
|
|
|
247
|
-
function applyReasoningTranslationConfig(
|
|
248
|
-
raw: Record<string, unknown>,
|
|
249
|
-
resolved: ReasoningTranslationConfig,
|
|
250
|
-
warnings: string[],
|
|
251
|
-
): void {
|
|
252
|
-
warnUnknownFields(raw, REASONING_TRANSLATION_FIELDS, "reasoningTranslation", warnings);
|
|
253
|
-
resolved.enabled =
|
|
254
|
-
toBoolean(raw.enabled, "reasoningTranslation.enabled", warnings) ?? resolved.enabled;
|
|
255
|
-
|
|
256
|
-
const models = toModelAllowlist(raw.models, "reasoningTranslation.models", warnings);
|
|
257
|
-
if (models !== undefined) {
|
|
258
|
-
resolved.models = models;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const modelSpec = toModelSpec(raw.model, "reasoningTranslation.model", warnings);
|
|
262
|
-
if (modelSpec !== undefined) {
|
|
263
|
-
if (modelSpec !== null && !parseModelSpec(modelSpec)) {
|
|
264
|
-
warnings.push(
|
|
265
|
-
"Ignoring reasoningTranslation.model: expected \"provider/model-id\" or null.",
|
|
266
|
-
);
|
|
267
|
-
} else {
|
|
268
|
-
resolved.model = modelSpec === null ? undefined : modelSpec;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (raw.targetLanguage !== undefined) {
|
|
273
|
-
if (typeof raw.targetLanguage === "string" && raw.targetLanguage.trim().length > 0) {
|
|
274
|
-
resolved.targetLanguage = raw.targetLanguage.trim();
|
|
275
|
-
} else {
|
|
276
|
-
warnings.push("Ignoring reasoningTranslation.targetLanguage: expected a non-empty string.");
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
239
|
/**
|
|
282
240
|
* Load the canonical toolkit config from
|
|
283
241
|
* `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
|
|
@@ -308,14 +266,6 @@ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolk
|
|
|
308
266
|
warnings.push("Ignoring webSearch: expected a JSON object.");
|
|
309
267
|
}
|
|
310
268
|
}
|
|
311
|
-
|
|
312
|
-
if (raw.reasoningTranslation !== undefined) {
|
|
313
|
-
if (isRecord(raw.reasoningTranslation)) {
|
|
314
|
-
applyReasoningTranslationConfig(raw.reasoningTranslation, resolved.reasoningTranslation, warnings);
|
|
315
|
-
} else {
|
|
316
|
-
warnings.push("Ignoring reasoningTranslation: expected a JSON object.");
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
269
|
}
|
|
320
270
|
|
|
321
271
|
resolved.compaction.artifactRoot = resolveConfiguredPath(
|
package/src/types.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { CompactionEntry, CompactionResult, ExtensionContext } from "@earen
|
|
|
4
4
|
export const TOOLKIT_ID = "pi-openai-toolkit";
|
|
5
5
|
export const COMPACTION_EXTENSION_ID = `${TOOLKIT_ID}:compaction`;
|
|
6
6
|
export const WEB_SEARCH_EXTENSION_ID = `${TOOLKIT_ID}:web-search`;
|
|
7
|
-
export const REASONING_TRANSLATION_EXTENSION_ID = `${TOOLKIT_ID}:reasoning-translation`;
|
|
8
7
|
export const DEFAULT_ARTIFACT_ROOT = "~/.pi/agent/artifacts/pi-openai-toolkit/compaction";
|
|
9
8
|
export const REDACTED_VALUE = "[REDACTED]";
|
|
10
9
|
/**
|
|
@@ -72,19 +71,9 @@ export type WebSearchConfig = {
|
|
|
72
71
|
models: string[];
|
|
73
72
|
};
|
|
74
73
|
|
|
75
|
-
export type ReasoningTranslationConfig = {
|
|
76
|
-
enabled: boolean;
|
|
77
|
-
/** Exact provider/model keys whose thinking output may be translated. */
|
|
78
|
-
models: string[];
|
|
79
|
-
/** Separate provider/model-id used only for translation requests. */
|
|
80
|
-
model?: string;
|
|
81
|
-
targetLanguage: string;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
74
|
export type ToolkitConfig = {
|
|
85
75
|
compaction: CompactionConfig;
|
|
86
76
|
webSearch: WebSearchConfig;
|
|
87
|
-
reasoningTranslation: ReasoningTranslationConfig;
|
|
88
77
|
};
|
|
89
78
|
|
|
90
79
|
export type LoadedToolkitConfig = {
|
|
@@ -356,15 +345,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchConfig = {
|
|
|
356
345
|
models: [],
|
|
357
346
|
};
|
|
358
347
|
|
|
359
|
-
export const DEFAULT_REASONING_TRANSLATION_CONFIG: ReasoningTranslationConfig = {
|
|
360
|
-
enabled: true,
|
|
361
|
-
models: [],
|
|
362
|
-
model: undefined,
|
|
363
|
-
targetLanguage: "Simplified Chinese",
|
|
364
|
-
};
|
|
365
|
-
|
|
366
348
|
export const DEFAULT_TOOLKIT_CONFIG: ToolkitConfig = {
|
|
367
349
|
compaction: DEFAULT_COMPACTION_CONFIG,
|
|
368
350
|
webSearch: DEFAULT_WEB_SEARCH_CONFIG,
|
|
369
|
-
reasoningTranslation: DEFAULT_REASONING_TRANSLATION_CONFIG,
|
|
370
351
|
};
|
package/tsconfig.check.json
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
"include": [
|
|
15
15
|
"extensions/compaction.ts",
|
|
16
16
|
"extensions/web-search.ts",
|
|
17
|
-
"extensions/reasoning-translation.ts",
|
|
18
17
|
"src/compact-client.ts",
|
|
19
18
|
"src/config.ts",
|
|
20
19
|
"src/debug.ts",
|
|
@@ -32,12 +31,6 @@
|
|
|
32
31
|
"src/web-search/extension.ts",
|
|
33
32
|
"src/web-search/payload.ts",
|
|
34
33
|
"src/web-search/prompt.ts",
|
|
35
|
-
"src/web-search/types.ts"
|
|
36
|
-
"src/reasoning-translation/client.ts",
|
|
37
|
-
"src/reasoning-translation/extension.ts",
|
|
38
|
-
"src/reasoning-translation/persistence.ts",
|
|
39
|
-
"src/reasoning-translation/segmenter.ts",
|
|
40
|
-
"src/reasoning-translation/state.ts",
|
|
41
|
-
"src/reasoning-translation/types.ts"
|
|
34
|
+
"src/web-search/types.ts"
|
|
42
35
|
]
|
|
43
36
|
}
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import type { Api, AssistantMessage, Context, Model, Provider, Usage } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { parseModelSpec } from "../runtime";
|
|
4
|
-
import { MAX_TRANSLATION_OUTPUT_TOKENS, TRANSLATION_TIMEOUT_MS } from "./types";
|
|
5
|
-
|
|
6
|
-
export type TranslatorFailureReason =
|
|
7
|
-
| "invalid-model-spec"
|
|
8
|
-
| "model-not-found"
|
|
9
|
-
| "provider-not-found"
|
|
10
|
-
| "auth-failed"
|
|
11
|
-
| "aborted"
|
|
12
|
-
| "request-failed"
|
|
13
|
-
| "empty-output";
|
|
14
|
-
|
|
15
|
-
export type TranslateSegmentResult =
|
|
16
|
-
| { ok: true; text: string; usage?: Usage }
|
|
17
|
-
| { ok: false; reason: TranslatorFailureReason; errorMessage?: string };
|
|
18
|
-
|
|
19
|
-
export type ResolvedTranslator = {
|
|
20
|
-
model: Model<Api>;
|
|
21
|
-
provider: Provider;
|
|
22
|
-
apiKey?: string;
|
|
23
|
-
headers?: Record<string, string | null>;
|
|
24
|
-
env?: Record<string, string>;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
function errorMessage(error: unknown): string {
|
|
28
|
-
return error instanceof Error ? error.message : String(error);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isAbortError(error: unknown): boolean {
|
|
32
|
-
return (
|
|
33
|
-
(error instanceof DOMException && error.name === "AbortError") ||
|
|
34
|
-
(error instanceof Error && (error.name === "AbortError" || error.name === "ABORT_ERR"))
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function buildTranslationPrompt(targetLanguage: string): string {
|
|
39
|
-
return [
|
|
40
|
-
`Translate the following reasoning into ${targetLanguage}.`,
|
|
41
|
-
"Return only the faithful translation, with no summary, explanation, commentary, or added content.",
|
|
42
|
-
"Preserve code, commands, file paths, URLs, identifiers, formatting markers, and technical terms exactly unless they must be translated for grammatical context.",
|
|
43
|
-
].join(" ");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function resolveTranslator(
|
|
47
|
-
ctx: ExtensionContext,
|
|
48
|
-
modelSpec: string | undefined,
|
|
49
|
-
): Promise<ResolvedTranslator | { ok: false; reason: TranslatorFailureReason; errorMessage?: string }> {
|
|
50
|
-
if (!modelSpec) return { ok: false, reason: "invalid-model-spec" };
|
|
51
|
-
const parsed = parseModelSpec(modelSpec);
|
|
52
|
-
if (!parsed) return { ok: false, reason: "invalid-model-spec" };
|
|
53
|
-
|
|
54
|
-
let model: Model<Api> | undefined;
|
|
55
|
-
try {
|
|
56
|
-
model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
|
|
57
|
-
} catch (error) {
|
|
58
|
-
return { ok: false, reason: "model-not-found", errorMessage: errorMessage(error) };
|
|
59
|
-
}
|
|
60
|
-
if (!model) {
|
|
61
|
-
return { ok: false, reason: "model-not-found" };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let provider: Provider | undefined;
|
|
65
|
-
try {
|
|
66
|
-
provider = ctx.modelRegistry.getProvider(parsed.provider);
|
|
67
|
-
} catch (error) {
|
|
68
|
-
return { ok: false, reason: "provider-not-found", errorMessage: errorMessage(error) };
|
|
69
|
-
}
|
|
70
|
-
if (!provider) {
|
|
71
|
-
return { ok: false, reason: "provider-not-found" };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
76
|
-
if (!auth.ok) return { ok: false, reason: "auth-failed", errorMessage: auth.error };
|
|
77
|
-
return {
|
|
78
|
-
// Auth resolution may provide an OAuth-specific endpoint. The provider
|
|
79
|
-
// receives the effective model directly, so preserve that base URL while
|
|
80
|
-
// disabling reasoning on the cloned translator model.
|
|
81
|
-
model: {
|
|
82
|
-
...model,
|
|
83
|
-
...(auth.baseUrl ? { baseUrl: auth.baseUrl } : {}),
|
|
84
|
-
reasoning: false,
|
|
85
|
-
},
|
|
86
|
-
provider,
|
|
87
|
-
apiKey: auth.apiKey,
|
|
88
|
-
headers: auth.headers,
|
|
89
|
-
env: auth.env,
|
|
90
|
-
};
|
|
91
|
-
} catch (error) {
|
|
92
|
-
return { ok: false, reason: "auth-failed", errorMessage: errorMessage(error) };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function extractText(message: AssistantMessage): string {
|
|
97
|
-
return message.content
|
|
98
|
-
.filter((content): content is Extract<AssistantMessage["content"][number], { type: "text" }> => content.type === "text")
|
|
99
|
-
.map((content) => content.text)
|
|
100
|
-
.join("")
|
|
101
|
-
.trim();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export async function translateSegment(args: {
|
|
105
|
-
translator: ResolvedTranslator;
|
|
106
|
-
targetLanguage: string;
|
|
107
|
-
sourceSegment: string;
|
|
108
|
-
signal: AbortSignal;
|
|
109
|
-
}): Promise<TranslateSegmentResult> {
|
|
110
|
-
if (args.signal.aborted) return { ok: false, reason: "aborted" };
|
|
111
|
-
|
|
112
|
-
const context: Context = {
|
|
113
|
-
systemPrompt: buildTranslationPrompt(args.targetLanguage),
|
|
114
|
-
messages: [{ role: "user", content: args.sourceSegment, timestamp: Date.now() }],
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const controller = new AbortController();
|
|
118
|
-
let rejectAbort: ((reason?: unknown) => void) | undefined;
|
|
119
|
-
let timedOut = false;
|
|
120
|
-
const abortPromise = new Promise<never>((_, reject) => {
|
|
121
|
-
rejectAbort = reject;
|
|
122
|
-
});
|
|
123
|
-
const onAbort = () => {
|
|
124
|
-
controller.abort();
|
|
125
|
-
rejectAbort?.(new DOMException("Translation request aborted", "AbortError"));
|
|
126
|
-
};
|
|
127
|
-
args.signal.addEventListener("abort", onAbort, { once: true });
|
|
128
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
// Keep the call safe even when a ResolvedTranslator is supplied by a test
|
|
132
|
-
// seam or another internal caller instead of resolveTranslator().
|
|
133
|
-
const effectiveModel = args.translator.model.reasoning
|
|
134
|
-
? { ...args.translator.model, reasoning: false }
|
|
135
|
-
: args.translator.model;
|
|
136
|
-
const stream = args.translator.provider.streamSimple(effectiveModel, context, {
|
|
137
|
-
apiKey: args.translator.apiKey,
|
|
138
|
-
headers: args.translator.headers,
|
|
139
|
-
env: args.translator.env,
|
|
140
|
-
signal: controller.signal,
|
|
141
|
-
timeoutMs: TRANSLATION_TIMEOUT_MS,
|
|
142
|
-
maxTokens: Math.max(1, Math.min(args.translator.model.maxTokens, MAX_TRANSLATION_OUTPUT_TOKENS)),
|
|
143
|
-
});
|
|
144
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
145
|
-
timeoutId = setTimeout(() => {
|
|
146
|
-
timedOut = true;
|
|
147
|
-
controller.abort();
|
|
148
|
-
reject(new Error(`Translation request timed out after ${TRANSLATION_TIMEOUT_MS} ms.`));
|
|
149
|
-
}, TRANSLATION_TIMEOUT_MS);
|
|
150
|
-
});
|
|
151
|
-
const message = await Promise.race([stream.result(), abortPromise, timeoutPromise]);
|
|
152
|
-
if (args.signal.aborted) return { ok: false, reason: "aborted" };
|
|
153
|
-
if (timedOut) {
|
|
154
|
-
return {
|
|
155
|
-
ok: false,
|
|
156
|
-
reason: "request-failed",
|
|
157
|
-
errorMessage: `Translation request timed out after ${TRANSLATION_TIMEOUT_MS} ms.`,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
if (message.stopReason === "aborted") {
|
|
161
|
-
return { ok: false, reason: "request-failed", errorMessage: "Translator aborted the request." };
|
|
162
|
-
}
|
|
163
|
-
if (message.stopReason !== "stop" && message.stopReason !== "length") {
|
|
164
|
-
return { ok: false, reason: "request-failed", errorMessage: message.errorMessage };
|
|
165
|
-
}
|
|
166
|
-
const text = extractText(message);
|
|
167
|
-
return text.length > 0
|
|
168
|
-
? { ok: true, text, usage: message.usage }
|
|
169
|
-
: { ok: false, reason: "empty-output" };
|
|
170
|
-
} catch (error) {
|
|
171
|
-
if (args.signal.aborted) return { ok: false, reason: "aborted" };
|
|
172
|
-
if (timedOut) {
|
|
173
|
-
return {
|
|
174
|
-
ok: false,
|
|
175
|
-
reason: "request-failed",
|
|
176
|
-
errorMessage: `Translation request timed out after ${TRANSLATION_TIMEOUT_MS} ms.`,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
if (isAbortError(error)) {
|
|
180
|
-
return { ok: false, reason: "request-failed", errorMessage: errorMessage(error) };
|
|
181
|
-
}
|
|
182
|
-
return { ok: false, reason: "request-failed", errorMessage: errorMessage(error) };
|
|
183
|
-
} finally {
|
|
184
|
-
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
185
|
-
args.signal.removeEventListener("abort", onAbort);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadToolkitConfig } from "../config";
|
|
3
|
-
import type { LoadedToolkitConfig } from "../types";
|
|
4
|
-
import { ReasoningTranslationState } from "./state";
|
|
5
|
-
|
|
6
|
-
function refreshThinkingDisplay(ctx: ExtensionContext | undefined): void {
|
|
7
|
-
if (!ctx || ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
8
|
-
try {
|
|
9
|
-
// Pi exposes no direct transcript invalidation API. Calling the public
|
|
10
|
-
// setter rebuilds assistant components and reruns transformers, while the
|
|
11
|
-
// no-argument form avoids leaving "Translating…" as the global hidden label.
|
|
12
|
-
ctx.ui.setHiddenThinkingLabel();
|
|
13
|
-
} catch {
|
|
14
|
-
// A display refresh is additive; never break the main model stream.
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function restoreThinkingDisplay(ctx: ExtensionContext | undefined): void {
|
|
19
|
-
if (!ctx || ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
20
|
-
try {
|
|
21
|
-
ctx.ui.setHiddenThinkingLabel();
|
|
22
|
-
} catch {
|
|
23
|
-
// Shutdown/reload must remain failure-open.
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function registerReasoningTranslationExtension(
|
|
28
|
-
pi: ExtensionAPI,
|
|
29
|
-
loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
|
|
30
|
-
): void {
|
|
31
|
-
let latestContext: ExtensionContext | undefined;
|
|
32
|
-
const state = new ReasoningTranslationState({
|
|
33
|
-
onDisplayChanged: () => refreshThinkingDisplay(latestContext),
|
|
34
|
-
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
pi.registerMarkdownTransformer((markdown, context) => {
|
|
38
|
-
if (context.messageType !== "assistant-thinking" || latestContext?.mode !== "tui") return markdown;
|
|
39
|
-
return state.lookupDisplay(markdown) ?? markdown;
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
pi.on("session_start", (_event, ctx) => {
|
|
43
|
-
latestContext = ctx;
|
|
44
|
-
state.abortActive();
|
|
45
|
-
const { config } = loadConfig() as LoadedToolkitConfig;
|
|
46
|
-
state.hydrate(ctx, config.reasoningTranslation);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
pi.on("session_tree", (_event, ctx) => {
|
|
50
|
-
latestContext = ctx;
|
|
51
|
-
state.abortActive();
|
|
52
|
-
const { config } = loadConfig() as LoadedToolkitConfig;
|
|
53
|
-
state.hydrate(ctx, config.reasoningTranslation);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
pi.on("session_before_switch", () => {
|
|
57
|
-
state.abortActive();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
pi.on("session_before_fork", () => {
|
|
61
|
-
state.abortActive();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
pi.on("session_before_tree", () => {
|
|
65
|
-
state.abortActive();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
69
|
-
if (latestContext === ctx) latestContext = undefined;
|
|
70
|
-
state.abortActive();
|
|
71
|
-
restoreThinkingDisplay(ctx);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
pi.on("model_select", (_event, ctx) => {
|
|
75
|
-
latestContext = ctx;
|
|
76
|
-
state.abortActive();
|
|
77
|
-
const { config } = loadConfig() as LoadedToolkitConfig;
|
|
78
|
-
state.setDisplayEligibility(ctx, config.reasoningTranslation);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
pi.on("message_start", (event, ctx) => {
|
|
82
|
-
latestContext = ctx;
|
|
83
|
-
const { config } = loadConfig() as LoadedToolkitConfig;
|
|
84
|
-
state.beginMessage(event.message, ctx, config.reasoningTranslation);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
pi.on("message_update", (event, ctx) => {
|
|
88
|
-
latestContext = ctx;
|
|
89
|
-
if (ctx.mode !== "tui") return;
|
|
90
|
-
const update = event.assistantMessageEvent;
|
|
91
|
-
if (update.type !== "thinking_delta" && update.type !== "thinking_end") return;
|
|
92
|
-
state.handleMessageUpdate(event.message, update.type, update.contentIndex);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
pi.on("message_end", (event, ctx) => {
|
|
96
|
-
latestContext = ctx;
|
|
97
|
-
if (ctx.mode !== "tui") return;
|
|
98
|
-
state.finishMessage(event.message);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export default function reasoningTranslationExtension(pi: ExtensionAPI): void {
|
|
104
|
-
registerReasoningTranslationExtension(pi);
|
|
105
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
3
|
-
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
5
|
-
REASONING_TRANSLATION_ENTRY_TYPE,
|
|
6
|
-
type ReasoningTranslationEntryV1,
|
|
7
|
-
cloneUsage,
|
|
8
|
-
isReasoningTranslationEntry,
|
|
9
|
-
} from "./types";
|
|
10
|
-
|
|
11
|
-
export type ThinkingRunProjection = {
|
|
12
|
-
runKey: number;
|
|
13
|
-
sourceMarkdown: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function projectThinkingRuns(message: Pick<AssistantMessage, "content">): ThinkingRunProjection[] {
|
|
17
|
-
const runs: ThinkingRunProjection[] = [];
|
|
18
|
-
for (let index = 0; index < message.content.length; index++) {
|
|
19
|
-
const content = message.content[index];
|
|
20
|
-
if (content.type !== "thinking") continue;
|
|
21
|
-
|
|
22
|
-
const runKey = index;
|
|
23
|
-
const blocks: string[] = [];
|
|
24
|
-
for (; index < message.content.length; index++) {
|
|
25
|
-
const block = message.content[index];
|
|
26
|
-
if (block.type !== "thinking") break;
|
|
27
|
-
const thinking = block.thinking.trim();
|
|
28
|
-
if (thinking) blocks.push(thinking);
|
|
29
|
-
}
|
|
30
|
-
index--;
|
|
31
|
-
if (blocks.length > 0) {
|
|
32
|
-
runs.push({ runKey, sourceMarkdown: blocks.join("\n\n") });
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return runs;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function hashThinkingMarkdown(sourceMarkdown: string): string {
|
|
39
|
-
return createHash("sha256").update(sourceMarkdown, "utf8").digest("hex");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function createReasoningTranslationEntry(args: {
|
|
43
|
-
sourceMarkdown: string;
|
|
44
|
-
displayText: string;
|
|
45
|
-
targetLanguage: string;
|
|
46
|
-
translator: { provider: string; model: string };
|
|
47
|
-
usage?: import("@earendil-works/pi-ai").Usage;
|
|
48
|
-
}): ReasoningTranslationEntryV1 {
|
|
49
|
-
return {
|
|
50
|
-
version: 1,
|
|
51
|
-
sourceHash: hashThinkingMarkdown(args.sourceMarkdown),
|
|
52
|
-
displayText: args.displayText,
|
|
53
|
-
targetLanguage: args.targetLanguage,
|
|
54
|
-
translator: { ...args.translator },
|
|
55
|
-
usage: cloneUsage(args.usage),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function replayReasoningTranslations(
|
|
60
|
-
entries: readonly SessionEntry[],
|
|
61
|
-
): Map<string, ReasoningTranslationEntryV1> {
|
|
62
|
-
const translations = new Map<string, ReasoningTranslationEntryV1>();
|
|
63
|
-
for (const entry of entries) {
|
|
64
|
-
if (entry.type !== "custom" || entry.customType !== REASONING_TRANSLATION_ENTRY_TYPE) continue;
|
|
65
|
-
if (!isReasoningTranslationEntry(entry.data)) continue;
|
|
66
|
-
translations.set(entry.data.sourceHash, {
|
|
67
|
-
...entry.data,
|
|
68
|
-
translator: { ...entry.data.translator },
|
|
69
|
-
usage: cloneUsage(entry.data.usage),
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
return translations;
|
|
73
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { MAX_SEGMENT_LENGTH } from "./types";
|
|
2
|
-
|
|
3
|
-
export type SegmentationResult = {
|
|
4
|
-
segments: string[];
|
|
5
|
-
remainder: string;
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
function isNaturalBoundaryCharacter(character: string): boolean {
|
|
9
|
-
return "\n.!?。!?;;".includes(character);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function findLatestNaturalBoundary(source: string, limit: number): number | undefined {
|
|
13
|
-
const end = Math.min(limit, source.length);
|
|
14
|
-
for (let index = end - 1; index >= 0; index--) {
|
|
15
|
-
if (!isNaturalBoundaryCharacter(source[index])) continue;
|
|
16
|
-
let boundary = index + 1;
|
|
17
|
-
while (boundary < end && /\s/u.test(source[boundary])) boundary++;
|
|
18
|
-
return boundary;
|
|
19
|
-
}
|
|
20
|
-
return undefined;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function addSegment(segments: string[], candidate: string): void {
|
|
24
|
-
if (candidate.trim().length > 0) segments.push(candidate);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Extract complete thinking segments without changing their source text. Natural
|
|
29
|
-
* boundaries are preferred; long unbroken text is cut at the hard UTF-16 limit.
|
|
30
|
-
*/
|
|
31
|
-
export function segmentThinkingSource(source: string, flush = false): SegmentationResult {
|
|
32
|
-
let remainder = source;
|
|
33
|
-
const segments: string[] = [];
|
|
34
|
-
|
|
35
|
-
while (remainder.length > 0) {
|
|
36
|
-
const boundary = findLatestNaturalBoundary(remainder, MAX_SEGMENT_LENGTH);
|
|
37
|
-
if (boundary !== undefined) {
|
|
38
|
-
addSegment(segments, remainder.slice(0, boundary));
|
|
39
|
-
remainder = remainder.slice(boundary);
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (remainder.length > MAX_SEGMENT_LENGTH) {
|
|
43
|
-
addSegment(segments, remainder.slice(0, MAX_SEGMENT_LENGTH));
|
|
44
|
-
remainder = remainder.slice(MAX_SEGMENT_LENGTH);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
break;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (flush && remainder.trim().length > 0) {
|
|
51
|
-
addSegment(segments, remainder);
|
|
52
|
-
remainder = "";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return { segments, remainder };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function flushThinkingSource(source: string): SegmentationResult {
|
|
59
|
-
return segmentThinkingSource(source, true);
|
|
60
|
-
}
|
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
|
|
3
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { parseModelSpec } from "../runtime";
|
|
5
|
-
import type { ReasoningTranslationConfig } from "../types";
|
|
6
|
-
import {
|
|
7
|
-
resolveTranslator,
|
|
8
|
-
translateSegment,
|
|
9
|
-
type ResolvedTranslator,
|
|
10
|
-
type TranslateSegmentResult,
|
|
11
|
-
} from "./client";
|
|
12
|
-
import {
|
|
13
|
-
createReasoningTranslationEntry,
|
|
14
|
-
hashThinkingMarkdown,
|
|
15
|
-
projectThinkingRuns,
|
|
16
|
-
replayReasoningTranslations,
|
|
17
|
-
type ThinkingRunProjection,
|
|
18
|
-
} from "./persistence";
|
|
19
|
-
import { flushThinkingSource, segmentThinkingSource } from "./segmenter";
|
|
20
|
-
import {
|
|
21
|
-
IDLE_FLUSH_MS,
|
|
22
|
-
REASONING_TRANSLATION_ENTRY_TYPE,
|
|
23
|
-
TRANSLATING_LABEL,
|
|
24
|
-
type ReasoningTranslationEntryV1,
|
|
25
|
-
} from "./types";
|
|
26
|
-
|
|
27
|
-
export type ReasoningTranslationStateOptions = {
|
|
28
|
-
resolveTranslator?: typeof resolveTranslator;
|
|
29
|
-
translateSegment?: typeof translateSegment;
|
|
30
|
-
setTimeout?: typeof globalThis.setTimeout;
|
|
31
|
-
clearTimeout?: typeof globalThis.clearTimeout;
|
|
32
|
-
onDisplayChanged?: () => void;
|
|
33
|
-
appendEntry?: (customType: string, data: ReasoningTranslationEntryV1) => void;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
type ThinkingRunState = {
|
|
37
|
-
runKey: number;
|
|
38
|
-
sourceMarkdown: string;
|
|
39
|
-
pendingSource: string;
|
|
40
|
-
displayedText: string;
|
|
41
|
-
usage?: Usage;
|
|
42
|
-
idleTimer?: ReturnType<typeof setTimeout>;
|
|
43
|
-
/** A natural/idle/final flush requested before the translator was ready. */
|
|
44
|
-
flushRequested: boolean;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
type ActiveTranslation = {
|
|
48
|
-
generation: number;
|
|
49
|
-
sourceModelKey: string;
|
|
50
|
-
targetLanguage: string;
|
|
51
|
-
translator?: ResolvedTranslator;
|
|
52
|
-
translatorReady: boolean;
|
|
53
|
-
translatorFailed: boolean;
|
|
54
|
-
translatorPromise?: Promise<void>;
|
|
55
|
-
abortController: AbortController;
|
|
56
|
-
queue: Promise<void>;
|
|
57
|
-
runs: Map<number, ThinkingRunState>;
|
|
58
|
-
ctx: ExtensionContext;
|
|
59
|
-
finalized: boolean;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
|
|
63
|
-
return message.role === "assistant";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function getModelKey(ctx: ExtensionContext): string | undefined {
|
|
67
|
-
return ctx.model?.provider && ctx.model.id ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function isEligible(ctx: ExtensionContext, config: ReasoningTranslationConfig): boolean {
|
|
71
|
-
const modelKey = getModelKey(ctx);
|
|
72
|
-
return (
|
|
73
|
-
ctx.mode === "tui" &&
|
|
74
|
-
config.enabled &&
|
|
75
|
-
modelKey !== undefined &&
|
|
76
|
-
config.models.includes(modelKey) &&
|
|
77
|
-
config.model !== undefined &&
|
|
78
|
-
parseModelSpec(config.model) !== undefined
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function preserveSourceWhitespace(source: string, translated: string): string {
|
|
83
|
-
const leading = source.match(/^\s*/u)?.[0] ?? "";
|
|
84
|
-
const trailing = source.match(/\s*$/u)?.[0] ?? "";
|
|
85
|
-
const core = translated.trim();
|
|
86
|
-
return `${leading}${core}${trailing}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function addUsage(left: Usage | undefined, right: Usage | undefined): Usage | undefined {
|
|
90
|
-
if (!left) return right;
|
|
91
|
-
if (!right) return left;
|
|
92
|
-
return {
|
|
93
|
-
input: left.input + right.input,
|
|
94
|
-
output: left.output + right.output,
|
|
95
|
-
cacheRead: left.cacheRead + right.cacheRead,
|
|
96
|
-
cacheWrite: left.cacheWrite + right.cacheWrite,
|
|
97
|
-
...(left.cacheWrite1h !== undefined || right.cacheWrite1h !== undefined
|
|
98
|
-
? { cacheWrite1h: (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0) }
|
|
99
|
-
: {}),
|
|
100
|
-
...(left.reasoning !== undefined || right.reasoning !== undefined
|
|
101
|
-
? { reasoning: (left.reasoning ?? 0) + (right.reasoning ?? 0) }
|
|
102
|
-
: {}),
|
|
103
|
-
totalTokens: left.totalTokens + right.totalTokens,
|
|
104
|
-
cost: {
|
|
105
|
-
input: left.cost.input + right.cost.input,
|
|
106
|
-
output: left.cost.output + right.cost.output,
|
|
107
|
-
cacheRead: left.cost.cacheRead + right.cost.cacheRead,
|
|
108
|
-
cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
|
|
109
|
-
total: left.cost.total + right.cost.total,
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function findRunForContentIndex(runs: readonly ThinkingRunProjection[], contentIndex: number): ThinkingRunProjection | undefined {
|
|
115
|
-
let selected: ThinkingRunProjection | undefined;
|
|
116
|
-
for (const run of runs) {
|
|
117
|
-
if (run.runKey <= contentIndex) selected = run;
|
|
118
|
-
else break;
|
|
119
|
-
}
|
|
120
|
-
return selected;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Lifecycle-local state for one translation extension instance. It owns no Pi
|
|
125
|
-
* session data; persisted projections are rebuilt from the active branch.
|
|
126
|
-
*/
|
|
127
|
-
export class ReasoningTranslationState {
|
|
128
|
-
private readonly options: Required<Pick<ReasoningTranslationStateOptions, "resolveTranslator" | "translateSegment" | "setTimeout" | "clearTimeout">> &
|
|
129
|
-
Pick<ReasoningTranslationStateOptions, "onDisplayChanged" | "appendEntry">;
|
|
130
|
-
private generation = 0;
|
|
131
|
-
private active: ActiveTranslation | undefined;
|
|
132
|
-
private persisted = new Map<string, ReasoningTranslationEntryV1>();
|
|
133
|
-
private displayEnabled = false;
|
|
134
|
-
|
|
135
|
-
constructor(options: ReasoningTranslationStateOptions = {}) {
|
|
136
|
-
this.options = {
|
|
137
|
-
resolveTranslator: options.resolveTranslator ?? resolveTranslator,
|
|
138
|
-
translateSegment: options.translateSegment ?? translateSegment,
|
|
139
|
-
setTimeout: options.setTimeout ?? globalThis.setTimeout,
|
|
140
|
-
clearTimeout: options.clearTimeout ?? globalThis.clearTimeout,
|
|
141
|
-
onDisplayChanged: options.onDisplayChanged,
|
|
142
|
-
appendEntry: options.appendEntry,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
hydrate(ctx: ExtensionContext, config?: ReasoningTranslationConfig): void {
|
|
147
|
-
this.persisted = replayReasoningTranslations(ctx.sessionManager.getBranch());
|
|
148
|
-
this.displayEnabled = config ? isEligible(ctx, config) : false;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
setDisplayEligibility(ctx: ExtensionContext, config: ReasoningTranslationConfig): void {
|
|
152
|
-
this.displayEnabled = isEligible(ctx, config);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
getPersistedDisplay(markdown: string): string | undefined {
|
|
156
|
-
if (!this.displayEnabled) return undefined;
|
|
157
|
-
return this.persisted.get(hashThinkingMarkdown(markdown))?.displayText;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
getActiveDisplay(markdown: string): string | undefined {
|
|
161
|
-
const active = this.active;
|
|
162
|
-
if (!active || active.ctx.mode !== "tui") return undefined;
|
|
163
|
-
for (const run of active.runs.values()) {
|
|
164
|
-
if (run.sourceMarkdown !== markdown) continue;
|
|
165
|
-
if (run.displayedText.length > 0) return run.displayedText;
|
|
166
|
-
if (!active.translatorFailed) return TRANSLATING_LABEL;
|
|
167
|
-
}
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
lookupDisplay(markdown: string): string | undefined {
|
|
172
|
-
return this.getActiveDisplay(markdown) ?? this.getPersistedDisplay(markdown);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
beginMessage(message: AgentMessage, ctx: ExtensionContext, config: ReasoningTranslationConfig): void {
|
|
176
|
-
const eligible = isEligible(ctx, config);
|
|
177
|
-
this.displayEnabled = eligible;
|
|
178
|
-
// message_start also fires for toolResult and user messages. They must not
|
|
179
|
-
// cancel a finished assistant translation before its FIFO queue can settle.
|
|
180
|
-
if (!isAssistantMessage(message)) return;
|
|
181
|
-
this.abortActive();
|
|
182
|
-
if (!eligible) return;
|
|
183
|
-
|
|
184
|
-
const sourceModelKey = getModelKey(ctx);
|
|
185
|
-
if (!sourceModelKey) return;
|
|
186
|
-
const generation = ++this.generation;
|
|
187
|
-
const abortController = new AbortController();
|
|
188
|
-
const active: ActiveTranslation = {
|
|
189
|
-
generation,
|
|
190
|
-
sourceModelKey,
|
|
191
|
-
targetLanguage: config.targetLanguage,
|
|
192
|
-
translatorReady: false,
|
|
193
|
-
translatorFailed: false,
|
|
194
|
-
abortController,
|
|
195
|
-
queue: Promise.resolve(),
|
|
196
|
-
runs: new Map(),
|
|
197
|
-
ctx,
|
|
198
|
-
finalized: false,
|
|
199
|
-
};
|
|
200
|
-
this.active = active;
|
|
201
|
-
|
|
202
|
-
if (ctx.signal) {
|
|
203
|
-
if (ctx.signal.aborted) {
|
|
204
|
-
this.abortActive();
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
ctx.signal.addEventListener("abort", () => {
|
|
208
|
-
if (this.active?.generation === generation) this.abortActive();
|
|
209
|
-
}, { once: true });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const resolve = this.options.resolveTranslator(ctx, config.model).then((result) => {
|
|
213
|
-
if (this.active?.generation !== generation) return;
|
|
214
|
-
if (!("model" in result) || !("provider" in result)) {
|
|
215
|
-
active.translatorFailed = true;
|
|
216
|
-
this.displayEnabled = false;
|
|
217
|
-
this.abortActive();
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
active.translator = result;
|
|
221
|
-
active.translatorReady = true;
|
|
222
|
-
for (const run of active.runs.values()) this.enqueuePending(active, run, run.flushRequested);
|
|
223
|
-
});
|
|
224
|
-
active.translatorPromise = resolve.catch(() => {
|
|
225
|
-
if (this.active?.generation === generation) {
|
|
226
|
-
active.translatorFailed = true;
|
|
227
|
-
this.displayEnabled = false;
|
|
228
|
-
this.abortActive();
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
handleMessageUpdate(message: AgentMessage, updateType: string, contentIndex: number): void {
|
|
234
|
-
const active = this.active;
|
|
235
|
-
if (!active || !isAssistantMessage(message) || active.ctx.mode !== "tui") return;
|
|
236
|
-
if (active.ctx.signal?.aborted) {
|
|
237
|
-
this.abortActive();
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const projections = projectThinkingRuns(message);
|
|
242
|
-
for (const projection of projections) this.updateRunSource(active, projection);
|
|
243
|
-
if (updateType === "thinking_end") {
|
|
244
|
-
const projection = findRunForContentIndex(projections, contentIndex);
|
|
245
|
-
if (projection) {
|
|
246
|
-
const run = active.runs.get(projection.runKey);
|
|
247
|
-
if (run) this.enqueuePending(active, run, true);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
finishMessage(message: AgentMessage): void {
|
|
253
|
-
if (isAssistantMessage(message) && (message.stopReason === "aborted" || message.stopReason === "error")) {
|
|
254
|
-
this.abortActive();
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
const active = this.active;
|
|
258
|
-
if (!active || !isAssistantMessage(message) || active.finalized) return;
|
|
259
|
-
const projections = projectThinkingRuns(message);
|
|
260
|
-
for (const projection of projections) this.updateRunSource(active, projection);
|
|
261
|
-
for (const run of active.runs.values()) this.enqueuePending(active, run, true);
|
|
262
|
-
active.finalized = true;
|
|
263
|
-
|
|
264
|
-
const generation = active.generation;
|
|
265
|
-
void (active.translatorPromise ?? Promise.resolve())
|
|
266
|
-
.then(() => active.queue)
|
|
267
|
-
.then(() => {
|
|
268
|
-
if (this.active?.generation !== generation || !this.active || !active.translator) return;
|
|
269
|
-
for (const run of active.runs.values()) {
|
|
270
|
-
if (!run.sourceMarkdown || !run.displayedText) continue;
|
|
271
|
-
const entry = createReasoningTranslationEntry({
|
|
272
|
-
sourceMarkdown: run.sourceMarkdown,
|
|
273
|
-
displayText: run.displayedText,
|
|
274
|
-
targetLanguage: active.targetLanguage,
|
|
275
|
-
translator: { provider: active.translator.model.provider, model: active.translator.model.id },
|
|
276
|
-
usage: run.usage,
|
|
277
|
-
});
|
|
278
|
-
this.persisted.set(entry.sourceHash, entry);
|
|
279
|
-
try {
|
|
280
|
-
this.options.appendEntry?.(REASONING_TRANSLATION_ENTRY_TYPE, entry);
|
|
281
|
-
} catch {
|
|
282
|
-
// Persistence is additive and must not affect the completed response.
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
abortActive(): void {
|
|
289
|
-
const active = this.active;
|
|
290
|
-
if (!active) return;
|
|
291
|
-
for (const run of active.runs.values()) {
|
|
292
|
-
if (run.idleTimer !== undefined) this.options.clearTimeout(run.idleTimer);
|
|
293
|
-
}
|
|
294
|
-
active.abortController.abort();
|
|
295
|
-
this.generation++;
|
|
296
|
-
this.active = undefined;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
private updateRunSource(active: ActiveTranslation, projection: ThinkingRunProjection): void {
|
|
300
|
-
let run = active.runs.get(projection.runKey);
|
|
301
|
-
if (!run) {
|
|
302
|
-
run = {
|
|
303
|
-
runKey: projection.runKey,
|
|
304
|
-
sourceMarkdown: "",
|
|
305
|
-
pendingSource: "",
|
|
306
|
-
displayedText: "",
|
|
307
|
-
flushRequested: false,
|
|
308
|
-
};
|
|
309
|
-
active.runs.set(projection.runKey, run);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (projection.sourceMarkdown === run.sourceMarkdown) return;
|
|
313
|
-
const previous = run.sourceMarkdown;
|
|
314
|
-
const suffix = projection.sourceMarkdown.startsWith(previous)
|
|
315
|
-
? projection.sourceMarkdown.slice(previous.length)
|
|
316
|
-
: projection.sourceMarkdown.slice(commonPrefixLength(previous, projection.sourceMarkdown));
|
|
317
|
-
run.sourceMarkdown = projection.sourceMarkdown;
|
|
318
|
-
if (suffix.length > 0) run.pendingSource += suffix;
|
|
319
|
-
this.scheduleIdleFlush(active, run);
|
|
320
|
-
if (active.translatorReady) this.enqueuePending(active, run, false);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private scheduleIdleFlush(active: ActiveTranslation, run: ThinkingRunState): void {
|
|
324
|
-
if (run.idleTimer !== undefined) this.options.clearTimeout(run.idleTimer);
|
|
325
|
-
const generation = active.generation;
|
|
326
|
-
run.idleTimer = this.options.setTimeout(() => {
|
|
327
|
-
if (this.active?.generation !== generation) return;
|
|
328
|
-
this.enqueuePending(active, run, true);
|
|
329
|
-
}, IDLE_FLUSH_MS);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
private enqueuePending(active: ActiveTranslation, run: ThinkingRunState, flush: boolean): void {
|
|
333
|
-
if (flush) run.flushRequested = true;
|
|
334
|
-
if (!active.translatorReady || !active.translator) return;
|
|
335
|
-
|
|
336
|
-
const shouldFlush = run.flushRequested;
|
|
337
|
-
const segmented = shouldFlush
|
|
338
|
-
? flushThinkingSource(run.pendingSource)
|
|
339
|
-
: segmentThinkingSource(run.pendingSource);
|
|
340
|
-
run.pendingSource = segmented.remainder;
|
|
341
|
-
if (shouldFlush && run.pendingSource.length === 0) run.flushRequested = false;
|
|
342
|
-
for (const segment of segmented.segments) {
|
|
343
|
-
const generation = active.generation;
|
|
344
|
-
active.queue = active.queue
|
|
345
|
-
.then(async () => {
|
|
346
|
-
if (this.active?.generation !== generation || active.abortController.signal.aborted) return;
|
|
347
|
-
const result = await this.options.translateSegment!({
|
|
348
|
-
translator: active.translator!,
|
|
349
|
-
targetLanguage: active.targetLanguage,
|
|
350
|
-
sourceSegment: segment,
|
|
351
|
-
signal: active.abortController.signal,
|
|
352
|
-
});
|
|
353
|
-
this.publishSegment(active, run, segment, result);
|
|
354
|
-
})
|
|
355
|
-
.catch(() => {
|
|
356
|
-
if (this.active?.generation === generation && !active.abortController.signal.aborted) {
|
|
357
|
-
this.publishSegment(active, run, segment, { ok: false, reason: "request-failed" });
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
if (shouldFlush && run.idleTimer !== undefined) {
|
|
362
|
-
this.options.clearTimeout(run.idleTimer);
|
|
363
|
-
run.idleTimer = undefined;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
private publishSegment(
|
|
368
|
-
active: ActiveTranslation,
|
|
369
|
-
run: ThinkingRunState,
|
|
370
|
-
sourceSegment: string,
|
|
371
|
-
result: TranslateSegmentResult,
|
|
372
|
-
): void {
|
|
373
|
-
if (this.active?.generation !== active.generation) return;
|
|
374
|
-
if (result.ok) {
|
|
375
|
-
run.displayedText += preserveSourceWhitespace(sourceSegment, result.text);
|
|
376
|
-
run.usage = addUsage(run.usage, result.usage);
|
|
377
|
-
} else {
|
|
378
|
-
// A stale result is filtered by the generation check above. Any failure
|
|
379
|
-
// that reaches this method is therefore a per-segment fallback, including
|
|
380
|
-
// a provider-reported abort that did not abort this active session.
|
|
381
|
-
run.displayedText += sourceSegment;
|
|
382
|
-
}
|
|
383
|
-
this.options.onDisplayChanged?.();
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function commonPrefixLength(left: string, right: string): number {
|
|
388
|
-
const limit = Math.min(left.length, right.length);
|
|
389
|
-
let index = 0;
|
|
390
|
-
while (index < limit && left[index] === right[index]) index++;
|
|
391
|
-
return index;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export function isReasoningTranslationEligible(
|
|
395
|
-
ctx: ExtensionContext,
|
|
396
|
-
config: ReasoningTranslationConfig,
|
|
397
|
-
): boolean {
|
|
398
|
-
return isEligible(ctx, config);
|
|
399
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
-
import { REASONING_TRANSLATION_EXTENSION_ID } from "../types";
|
|
3
|
-
|
|
4
|
-
export const REASONING_TRANSLATION_ENTRY_TYPE = REASONING_TRANSLATION_EXTENSION_ID;
|
|
5
|
-
export const TRANSLATING_LABEL = "Translating…";
|
|
6
|
-
export const IDLE_FLUSH_MS = 800;
|
|
7
|
-
export const MAX_SEGMENT_LENGTH = 400;
|
|
8
|
-
export const MAX_TRANSLATION_OUTPUT_TOKENS = 2048;
|
|
9
|
-
/** Hard upper bound so an uncooperative provider cannot hold the FIFO forever. */
|
|
10
|
-
export const TRANSLATION_TIMEOUT_MS = 30_000;
|
|
11
|
-
|
|
12
|
-
export type ReasoningTranslationEntryV1 = {
|
|
13
|
-
version: 1;
|
|
14
|
-
sourceHash: string;
|
|
15
|
-
displayText: string;
|
|
16
|
-
targetLanguage: string;
|
|
17
|
-
translator: {
|
|
18
|
-
provider: string;
|
|
19
|
-
model: string;
|
|
20
|
-
};
|
|
21
|
-
usage?: Usage;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isNonEmptyString(value: unknown): value is string {
|
|
29
|
-
return typeof value === "string" && value.trim().length > 0;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
|
33
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function isUsage(value: unknown): value is Usage {
|
|
37
|
-
if (!isRecord(value) || !isRecord(value.cost)) return false;
|
|
38
|
-
const cost = value.cost;
|
|
39
|
-
const numericFields = ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const;
|
|
40
|
-
if (numericFields.some((field) => !isFiniteNonNegativeNumber(value[field]))) return false;
|
|
41
|
-
if (value.reasoning !== undefined && !isFiniteNonNegativeNumber(value.reasoning)) return false;
|
|
42
|
-
if (value.cacheWrite1h !== undefined && !isFiniteNonNegativeNumber(value.cacheWrite1h)) return false;
|
|
43
|
-
return ["input", "output", "cacheRead", "cacheWrite", "total"].every((field) =>
|
|
44
|
-
isFiniteNonNegativeNumber(cost[field]),
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function isReasoningTranslationEntry(value: unknown): value is ReasoningTranslationEntryV1 {
|
|
49
|
-
if (!isRecord(value) || value.version !== 1) return false;
|
|
50
|
-
if (typeof value.sourceHash !== "string" || !/^[a-f0-9]{64}$/iu.test(value.sourceHash)) return false;
|
|
51
|
-
if (!isNonEmptyString(value.displayText)) return false;
|
|
52
|
-
if (!isNonEmptyString(value.targetLanguage) || !isRecord(value.translator)) return false;
|
|
53
|
-
if (!isNonEmptyString(value.translator.provider) || !isNonEmptyString(value.translator.model)) return false;
|
|
54
|
-
return value.usage === undefined || isUsage(value.usage);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function cloneUsage(usage: Usage | undefined): Usage | undefined {
|
|
58
|
-
return usage
|
|
59
|
-
? {
|
|
60
|
-
...usage,
|
|
61
|
-
cost: { ...usage.cost },
|
|
62
|
-
}
|
|
63
|
-
: undefined;
|
|
64
|
-
}
|