pi-openai-toolkit 0.4.1 → 0.5.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 +31 -2
- package/README_zh.md +31 -2
- package/extensions/reasoning-translation.ts +2 -0
- package/package.json +12 -3
- package/src/config.ts +51 -1
- package/src/reasoning-translation/client.ts +187 -0
- package/src/reasoning-translation/extension.ts +105 -0
- package/src/reasoning-translation/persistence.ts +73 -0
- package/src/reasoning-translation/segmenter.ts +60 -0
- package/src/reasoning-translation/state.ts +399 -0
- package/src/reasoning-translation/types.ts +64 -0
- package/src/types.ts +19 -0
- package/tsconfig.check.json +8 -1
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 & Real-time Reasoning Translation</strong>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
@@ -10,10 +10,11 @@
|
|
|
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 three independent extensions:
|
|
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.
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -57,6 +58,9 @@ pi --no-extensions -e /absolute/path/to/pi-openai-toolkit/extensions/compaction.
|
|
|
57
58
|
|
|
58
59
|
# Web Search only
|
|
59
60
|
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
|
|
60
64
|
```
|
|
61
65
|
|
|
62
66
|
> ⚠️ **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.
|
|
@@ -103,6 +107,14 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
103
107
|
"models": [
|
|
104
108
|
"uwoacrimson/gpt-5.6-luna"
|
|
105
109
|
]
|
|
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"
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
```
|
|
@@ -128,6 +140,19 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
128
140
|
- `enabled` (*boolean*, default: `true`): Enable or disable native OpenAI Web Search injection.
|
|
129
141
|
- `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.
|
|
130
142
|
|
|
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
|
+
|
|
131
156
|
---
|
|
132
157
|
|
|
133
158
|
## Core Mechanics
|
|
@@ -169,6 +194,10 @@ For allowlisted models using `openai-responses` or `openai-codex-responses`, the
|
|
|
169
194
|
- **Zero-Latency**: Uses Pi's existing connection, auth headers, and base URL without standalone HTTP overhead.
|
|
170
195
|
- **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.
|
|
171
196
|
|
|
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
|
+
|
|
172
201
|
---
|
|
173
202
|
|
|
174
203
|
## 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,10 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
`pi-openai-toolkit` 是专为 **Pi (>= 0.84.x)** 打造的 OpenAI
|
|
13
|
+
`pi-openai-toolkit` 是专为 **Pi (>= 0.84.x)** 打造的 OpenAI 全功能增强工具包,集成了三个可独立加载的扩展:
|
|
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 消息、会话原文或主模型上下文。
|
|
17
18
|
|
|
18
19
|
## 安装使用
|
|
19
20
|
|
|
@@ -57,6 +58,9 @@ pi --no-extensions -e /本地绝对路径/pi-openai-toolkit/extensions/compactio
|
|
|
57
58
|
|
|
58
59
|
# 仅加载原生联网搜索
|
|
59
60
|
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
|
|
60
64
|
```
|
|
61
65
|
|
|
62
66
|
> ⚠️ **冲突提示**:请勿同时启用 `pi-remote-compact`、`@lll9p/pi-better-compaction` 或独立的 `pi-openai-web-search`。多重钩子会产生竞态或注入重复的 System Prompt。
|
|
@@ -103,6 +107,14 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
103
107
|
"models": [
|
|
104
108
|
"uwoacrimson/gpt-5.6-luna"
|
|
105
109
|
]
|
|
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"
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
```
|
|
@@ -128,6 +140,19 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
128
140
|
- `enabled` (*boolean*, 默认 `true`): 是否启用原生 OpenAI 联网搜索注入。
|
|
129
141
|
- `models` (*string[]*): 精确的 `provider/model-id` 模型白名单。只有列表中的模型在 `openai-responses` 或 `openai-codex-responses` 端点上运行时,才会使用 Toolkit Web Search。空列表表示所有模型都不启用 Toolkit Web Search。
|
|
130
142
|
|
|
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
|
+
|
|
131
156
|
---
|
|
132
157
|
|
|
133
158
|
## 核心工作原理解析
|
|
@@ -169,6 +194,10 @@ Content-Type: application/json
|
|
|
169
194
|
- **零额外网络延迟**:复用 Pi 当前活跃的 Provider 配置、认证凭据与 Base URL;
|
|
170
195
|
- **Toolkit 接管搜索**:对于符合条件的模型,发给 Provider 的载荷会移除名为 `web_search` 的本地 Function Tool,只保留原生搜索;切换到不符合条件的模型后,会恢复该本地工具原先的 active 状态。
|
|
171
196
|
|
|
197
|
+
### 3. 实时思考翻译
|
|
198
|
+
|
|
199
|
+
该扩展与压缩、联网搜索彼此独立:只注册生命周期监听器和一个同步 Markdown transformer,不注册 Function Tool,也不改写主模型的 Provider request。翻译直接调用配置模型所属 Provider 的 `streamSimple()`,因此不会切换当前模型,也不会递归触发 Toolkit 的其他请求钩子。每个分段完成后通过 Pi 公共的 `setHiddenThinkingLabel()` 触发展示刷新,继续使用 Pi 原有 thinking 容器的颜色、斜体和折叠行为。
|
|
200
|
+
|
|
172
201
|
---
|
|
173
202
|
|
|
174
203
|
## 测试与验证
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openai-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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, native Web Search, and TUI reasoning translation.",
|
|
7
7
|
"author": "awoaCrim and contributors",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -24,18 +24,21 @@
|
|
|
24
24
|
"openai",
|
|
25
25
|
"compaction",
|
|
26
26
|
"web-search",
|
|
27
|
+
"reasoning-translation",
|
|
27
28
|
"codex",
|
|
28
29
|
"responses"
|
|
29
30
|
],
|
|
30
31
|
"pi": {
|
|
31
32
|
"extensions": [
|
|
32
33
|
"./extensions/compaction.ts",
|
|
33
|
-
"./extensions/web-search.ts"
|
|
34
|
+
"./extensions/web-search.ts",
|
|
35
|
+
"./extensions/reasoning-translation.ts"
|
|
34
36
|
]
|
|
35
37
|
},
|
|
36
38
|
"files": [
|
|
37
39
|
"extensions/compaction.ts",
|
|
38
40
|
"extensions/web-search.ts",
|
|
41
|
+
"extensions/reasoning-translation.ts",
|
|
39
42
|
"src/compact-client.ts",
|
|
40
43
|
"src/config.ts",
|
|
41
44
|
"src/debug.ts",
|
|
@@ -54,6 +57,12 @@
|
|
|
54
57
|
"src/web-search/payload.ts",
|
|
55
58
|
"src/web-search/prompt.ts",
|
|
56
59
|
"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",
|
|
57
66
|
"README.md",
|
|
58
67
|
"README_zh.md",
|
|
59
68
|
"LICENSE",
|
package/src/config.ts
CHANGED
|
@@ -2,8 +2,10 @@ 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";
|
|
5
6
|
import {
|
|
6
7
|
DEFAULT_COMPACTION_CONFIG,
|
|
8
|
+
DEFAULT_REASONING_TRANSLATION_CONFIG,
|
|
7
9
|
DEFAULT_TOOLKIT_CONFIG,
|
|
8
10
|
DEFAULT_WEB_SEARCH_CONFIG,
|
|
9
11
|
RESPONSES_COMPACT_CAPABLE_APIS,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
TOOLKIT_ID,
|
|
12
14
|
type CompactionConfig,
|
|
13
15
|
type LoadedToolkitConfig,
|
|
16
|
+
type ReasoningTranslationConfig,
|
|
14
17
|
type ToolkitConfig,
|
|
15
18
|
type WebSearchConfig,
|
|
16
19
|
} from "./types";
|
|
@@ -18,7 +21,7 @@ import {
|
|
|
18
21
|
export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
|
|
19
22
|
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
20
23
|
|
|
21
|
-
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch"]);
|
|
24
|
+
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "reasoningTranslation"]);
|
|
22
25
|
const COMPACTION_FIELDS = new Set([
|
|
23
26
|
"enabled",
|
|
24
27
|
"allowCompactionContinuityBreak",
|
|
@@ -34,6 +37,7 @@ const COMPACTION_FIELDS = new Set([
|
|
|
34
37
|
"artifactRoot",
|
|
35
38
|
]);
|
|
36
39
|
const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
|
|
40
|
+
const REASONING_TRANSLATION_FIELDS = new Set(["enabled", "models", "model", "targetLanguage"]);
|
|
37
41
|
|
|
38
42
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
43
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -158,6 +162,10 @@ function cloneDefaults(): ToolkitConfig {
|
|
|
158
162
|
...DEFAULT_WEB_SEARCH_CONFIG,
|
|
159
163
|
models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
|
|
160
164
|
},
|
|
165
|
+
reasoningTranslation: {
|
|
166
|
+
...DEFAULT_REASONING_TRANSLATION_CONFIG,
|
|
167
|
+
models: [...DEFAULT_REASONING_TRANSLATION_CONFIG.models],
|
|
168
|
+
},
|
|
161
169
|
};
|
|
162
170
|
}
|
|
163
171
|
|
|
@@ -236,6 +244,40 @@ function applyWebSearchConfig(
|
|
|
236
244
|
}
|
|
237
245
|
}
|
|
238
246
|
|
|
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
|
+
|
|
239
281
|
/**
|
|
240
282
|
* Load the canonical toolkit config from
|
|
241
283
|
* `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
|
|
@@ -266,6 +308,14 @@ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolk
|
|
|
266
308
|
warnings.push("Ignoring webSearch: expected a JSON object.");
|
|
267
309
|
}
|
|
268
310
|
}
|
|
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
|
+
}
|
|
269
319
|
}
|
|
270
320
|
|
|
271
321
|
resolved.compaction.artifactRoot = resolveConfiguredPath(
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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`;
|
|
7
8
|
export const DEFAULT_ARTIFACT_ROOT = "~/.pi/agent/artifacts/pi-openai-toolkit/compaction";
|
|
8
9
|
export const REDACTED_VALUE = "[REDACTED]";
|
|
9
10
|
/**
|
|
@@ -71,9 +72,19 @@ export type WebSearchConfig = {
|
|
|
71
72
|
models: string[];
|
|
72
73
|
};
|
|
73
74
|
|
|
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
|
+
|
|
74
84
|
export type ToolkitConfig = {
|
|
75
85
|
compaction: CompactionConfig;
|
|
76
86
|
webSearch: WebSearchConfig;
|
|
87
|
+
reasoningTranslation: ReasoningTranslationConfig;
|
|
77
88
|
};
|
|
78
89
|
|
|
79
90
|
export type LoadedToolkitConfig = {
|
|
@@ -345,7 +356,15 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchConfig = {
|
|
|
345
356
|
models: [],
|
|
346
357
|
};
|
|
347
358
|
|
|
359
|
+
export const DEFAULT_REASONING_TRANSLATION_CONFIG: ReasoningTranslationConfig = {
|
|
360
|
+
enabled: true,
|
|
361
|
+
models: [],
|
|
362
|
+
model: undefined,
|
|
363
|
+
targetLanguage: "Simplified Chinese",
|
|
364
|
+
};
|
|
365
|
+
|
|
348
366
|
export const DEFAULT_TOOLKIT_CONFIG: ToolkitConfig = {
|
|
349
367
|
compaction: DEFAULT_COMPACTION_CONFIG,
|
|
350
368
|
webSearch: DEFAULT_WEB_SEARCH_CONFIG,
|
|
369
|
+
reasoningTranslation: DEFAULT_REASONING_TRANSLATION_CONFIG,
|
|
351
370
|
};
|
package/tsconfig.check.json
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"include": [
|
|
15
15
|
"extensions/compaction.ts",
|
|
16
16
|
"extensions/web-search.ts",
|
|
17
|
+
"extensions/reasoning-translation.ts",
|
|
17
18
|
"src/compact-client.ts",
|
|
18
19
|
"src/config.ts",
|
|
19
20
|
"src/debug.ts",
|
|
@@ -31,6 +32,12 @@
|
|
|
31
32
|
"src/web-search/extension.ts",
|
|
32
33
|
"src/web-search/payload.ts",
|
|
33
34
|
"src/web-search/prompt.ts",
|
|
34
|
-
"src/web-search/types.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"
|
|
35
42
|
]
|
|
36
43
|
}
|