@alexlikevibe/pi-jev 0.2.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +340 -0
  3. package/README.zh-CN.md +340 -0
  4. package/bin/pi-jev.js +13 -0
  5. package/dist/cli/main.js +223 -0
  6. package/dist/commands/completions.js +87 -0
  7. package/dist/commands/extension.js +58 -0
  8. package/dist/commands/menu.js +245 -0
  9. package/dist/commands/models.js +23 -0
  10. package/dist/compaction/convert.js +87 -0
  11. package/dist/compaction/decision.js +195 -0
  12. package/dist/compaction/extension.js +150 -0
  13. package/dist/compaction/jev.js +72 -0
  14. package/dist/compaction/summarize.js +68 -0
  15. package/dist/routing/decide.js +57 -0
  16. package/dist/routing/extension.js +81 -0
  17. package/dist/shared/config.js +157 -0
  18. package/dist/vendor/fast-jev-compaction/client.js +25 -0
  19. package/dist/vendor/fast-jev-compaction/compact.js +233 -0
  20. package/dist/vendor/fast-jev-compaction/index.js +7 -0
  21. package/dist/vendor/fast-jev-compaction/request.js +50 -0
  22. package/dist/vendor/fast-jev-compaction/state.js +255 -0
  23. package/dist/vendor/fast-jev-compaction/types.js +1 -0
  24. package/extensions/compaction.ts +1 -0
  25. package/extensions/jev.ts +1 -0
  26. package/extensions/routing.ts +1 -0
  27. package/media/banner.svg +198 -0
  28. package/package.json +55 -0
  29. package/src/cli/main.ts +241 -0
  30. package/src/commands/completions.ts +107 -0
  31. package/src/commands/extension.ts +61 -0
  32. package/src/commands/menu.ts +291 -0
  33. package/src/commands/models.ts +43 -0
  34. package/src/compaction/convert.ts +95 -0
  35. package/src/compaction/decision.ts +262 -0
  36. package/src/compaction/extension.ts +235 -0
  37. package/src/compaction/jev.ts +133 -0
  38. package/src/compaction/summarize.ts +80 -0
  39. package/src/routing/decide.ts +81 -0
  40. package/src/routing/extension.ts +92 -0
  41. package/src/shared/config.ts +280 -0
  42. package/src/vendor/fast-jev-compaction/LICENSE +21 -0
  43. package/src/vendor/fast-jev-compaction/client.ts +43 -0
  44. package/src/vendor/fast-jev-compaction/compact.ts +309 -0
  45. package/src/vendor/fast-jev-compaction/index.ts +7 -0
  46. package/src/vendor/fast-jev-compaction/request.ts +80 -0
  47. package/src/vendor/fast-jev-compaction/state.ts +304 -0
  48. package/src/vendor/fast-jev-compaction/types.ts +202 -0
@@ -0,0 +1,340 @@
1
+ <div align="center">
2
+
3
+ # pi-jev
4
+
5
+ **由 [Jev](https://typesafe.ai) 驱动,为 [pi](https://github.com/earendil-works/pi-coding-agent) 提供选择性上下文压缩和逐轮模型路由。**
6
+
7
+ [English](README.md) · **简体中文**
8
+
9
+ <img src="media/banner.svg" alt="Pi 事件触发 Jev 类型化请求:保留评分决定上下文取舍,难度评分决定使用哪个模型" width="100%">
10
+
11
+ [![checks](https://img.shields.io/github/actions/workflow/status/iefnaf/pi-jev/test.yml?branch=main&style=for-the-badge&label=checks)](https://github.com/iefnaf/pi-jev/actions/workflows/test.yml)
12
+ [![pi extension](https://img.shields.io/badge/pi-extension-8b5cf6?style=for-the-badge)](https://github.com/earendil-works/pi-coding-agent)
13
+ [![Jev](https://img.shields.io/badge/Jev-TypeSafe%20%7C%20OpenRouter-0ea5e9?style=for-the-badge)](https://typesafe.ai)
14
+ [![license](https://img.shields.io/badge/license-MIT-f4c430?style=for-the-badge)](LICENSE)
15
+
16
+ </div>
17
+
18
+ pi-jev 使用 Jev 的类型化判断,识别仍有价值的工具输出,并评估用户请求的难度。压缩时保留有用的原文,而不是生成新的摘要;每轮开始前,还可以根据难度切换模型。两项功能均可选:Jev 不可用时,pi 会使用原生压缩或继续使用当前模型。
19
+
20
+ | 扩展 | 功能 | Pi 接入点 |
21
+ | --- | --- | --- |
22
+ | **上下文压缩** | 移除过时的工具调用,缩短不再需要的结果,其余文本保留原文 | `session_before_compact` |
23
+ | **模型路由** | 简单请求交给低成本模型,复杂请求交给能力更强的模型 | `before_agent_start` |
24
+ | **`/jev` 设置** | 通过交互菜单配置服务提供方、压缩和路由 | `/jev` 命令 |
25
+
26
+ 每个扩展都可通过 `pi config` 独立启停。配置会在下次钩子触发时读取,无需重启 pi。
27
+
28
+ ## 快速开始
29
+
30
+ 需要已安装 pi,并拥有 **TypeSafe 或 OpenRouter 中任意一个** Jev 接入服务的 API Key。路由目标模型还需要在 pi 中完成配置和认证。
31
+
32
+ ### 1. 配置 Jev API Key
33
+
34
+ 在启动 pi 的终端中,选择一种方式:
35
+
36
+ ```sh
37
+ # 方式 A:TypeSafe
38
+ export TYPESAFE_API_KEY="your-typesafe-api-key"
39
+ ```
40
+
41
+ ```sh
42
+ # 方式 B:OpenRouter
43
+ export OPENROUTER_API_KEY="your-openrouter-api-key"
44
+ ```
45
+
46
+ 未显式配置服务提供方时,项目会根据 API Key 自动选择。如果两个 Key 都存在,优先使用 TypeSafe;设置 `JEVC_PROVIDER=openrouter` 可显式选择 OpenRouter。
47
+
48
+ ### 2. 安装并启动
49
+
50
+ ```sh
51
+ pi install npm:@alexlikevibe/pi-jev
52
+ pi config # 检查已启用的 pi-jev 扩展
53
+ pi
54
+ ```
55
+
56
+ 如果在 pi 会话运行期间安装,使用 `/reload` 加载扩展。
57
+
58
+ ### 3. 在 pi 中配置
59
+
60
+ ```text
61
+ /jev
62
+ ```
63
+
64
+ 进入 **Routing**,从 pi 的模型列表选择 `cheap` 和/或 `strong` 目标,还可选择思考等级。**至少设置一个目标后,模型路由才会启用。** 上下文压缩不需要配置路由目标;可以通过 `/compact` 手动触发,也会随 pi 的自动压缩运行。
65
+
66
+ | 操作 | 命令或方式 |
67
+ | --- | --- |
68
+ | 打开设置 | `/jev` |
69
+ | 压缩当前会话 | `/compact` |
70
+ | 查看可用设置项 | `/jev keys` |
71
+ | 查看路由目标 | `/jev get routing.cheap` |
72
+ | 移除路由目标 | `/jev unset routing.strong` |
73
+ | 暂时跳过两项功能 | `/jev set disabled true` |
74
+ | 重新启用两项功能 | `/jev set disabled false` |
75
+ | 写入项目配置 | 添加 `-l`,例如 `/jev set provider openrouter -l` |
76
+
77
+ 配置**默认写入全局文件**,`disabled` 也不例外;使用 `-l` 可限定到项目。若只想对一次 pi 进程跳过两项功能,使用 `JEVC_DISABLED=1 pi` 启动。
78
+
79
+ <details>
80
+ <summary>从 GitHub、本地源码安装,或单独加载一个扩展</summary>
81
+
82
+ 直接从 GitHub 安装:
83
+
84
+ ```sh
85
+ pi install https://github.com/iefnaf/pi-jev
86
+ ```
87
+
88
+ 从本地源码安装:
89
+
90
+ ```sh
91
+ git clone https://github.com/iefnaf/pi-jev.git
92
+ cd pi-jev
93
+ npm ci
94
+ npm run build
95
+ pi install /absolute/path/to/pi-jev
96
+ ```
97
+
98
+ 在仓库根目录进行开发时,可使用:
99
+
100
+ ```sh
101
+ pi -e ./extensions/compaction.ts
102
+ pi -e ./extensions/routing.ts
103
+ pi -e ./extensions/jev.ts
104
+ ```
105
+
106
+ 每条命令显式加载一个扩展。信任此仓库后,项目的 `.pi/extensions/jev-*` 加载器也会被自动发现。
107
+
108
+ </details>
109
+
110
+ ## 上下文压缩
111
+
112
+ pi-jev 不让摘要模型改写整个对话,而是针对每个可评估的工具调用,向 Jev 提出类型化问题:是否保留调用、是否保留完整结果(`noul`),以及结果有多过时(`score`)。随后将保留内容渲染为对话记录。
113
+
114
+ - **保留:** 保留用户和助手文本、受保护的近期消息,以及有用的工具调用和结果。上一轮压缩摘要原样嵌入。
115
+ - **移除:** 过时的工具调用及其结果一起删除,记录头部会说明移除数量。
116
+ - **缩短:** 保留工具调用,将较长的结果缩短至前 `truncateHeadChars` 个字符,并添加明确的截断标记。较短的结果可能保持不变。
117
+ - **补救:** 如果结果的保留概率接近阈值,且过时程度判断以足够置信度表明它仍有用,则完整保留。
118
+ - **回退:** Jev 出错、请求中止、待压缩片段为空,或估算缩减比例低于 `minReduction` 时,交给 pi 生成原生摘要。单个调用的回答缺失或格式异常时,保守地保留该调用。
119
+
120
+ 请求会根据上下文和请求预算分批,因此一次压缩可能发出多个 Jev 请求。近期消息保护作用于 pi 提供的待压缩片段中经过转换的消息。
121
+
122
+ **“保留原文”针对保留的文本,并非原始消息的所有字段:** 转换时图片替换为 `[image]` 占位符,助手的思考块会被省略;工具输入会序列化到记录中。
123
+
124
+ ### 结果示例
125
+
126
+ 本项目记录的一次示例运行,将转换后的片段从 **32 条消息/39,379 个字符** 缩减至 **16 条消息/9,553 个字符**,耗时 **757 ms**,使用一次 Jev 请求和 652 个输出 token。这只是示例,并非延迟或压缩比例保证。
127
+
128
+ 成功的压缩会将审计数据写入会话的 `compaction` 条目:
129
+
130
+ ```json
131
+ {
132
+ "engine": "jev",
133
+ "stats": {
134
+ "messagesBefore": 32,
135
+ "messagesAfter": 16,
136
+ "charsBefore": 39379,
137
+ "charsAfter": 9553,
138
+ "calls": 15,
139
+ "pinned": 3,
140
+ "callsDropped": 12,
141
+ "ms": 757,
142
+ "requests": 1,
143
+ "jevUsage": { "input": 9784, "output": 652 }
144
+ }
145
+ }
146
+ ```
147
+
148
+ 以上是条目中 `details` 对象的精简示例。查看 `details.engine`、`details.stats` 和 `details.decisions` 可了解完整结果。钩子保留 `firstKeptEntryId`,供 pi 正确保留会话的剩余部分。
149
+
150
+ ## 模型路由
151
+
152
+ 每轮开始前,Jev 根据**当前用户提示**,按三个等级评估难度:
153
+
154
+ | 等级 | 典型请求 |
155
+ | --- | --- |
156
+ | `0` — 简单 | 问候、简短问题、格式调整、机械性的单文件修改 |
157
+ | `1` — 中等 | 日常编码任务 |
158
+ | `2` — 复杂 | 多文件重构、隐蔽问题调试、架构决策 |
159
+
160
+ 使用默认阈值时:
161
+
162
+ | 条件 | 动作 |
163
+ | --- | --- |
164
+ | 难度 ≤ `0.5`、置信度 ≥ `0.6`,且设置了 `routing.cheap` | 切换到低成本模型 |
165
+ | 难度 ≥ `1.5`、置信度 ≥ `0.6`,且设置了 `routing.strong` | 切换到强模型 |
166
+ | 中间难度、低置信度、回答缺失,或对应目标未设置 | 保持当前模型 |
167
+
168
+ **中间难度的请求会沿用当前模型,包括上一轮路由选择的模型。** 它不会自动切回初始默认模型。路由评估使用当前提示,而非完整对话历史。
169
+
170
+ 模型引用格式错误、模型不存在、服务提供方未认证,或 Jev 请求失败时,保持当前模型。提示包含图片时,会跳过仅支持文本的 `cheap` 目标。目标与当前模型相同时不执行切换。成功切换和路由错误通过 pi 界面通知显示。
171
+
172
+ 模型引用格式为 `provider/model-id`,可附加 `:thinking` 后缀,例如 `:high` 或 `:max`。建议通过 `/jev` 选择 pi 中实际配置的模型;思考等级在发生模型切换时应用。
173
+
174
+ ## 配置
175
+
176
+ 配置按以下顺序解析,优先级从高到低:
177
+
178
+ 1. 环境变量
179
+ 2. 项目配置:`.pi/jev.json`
180
+ 3. 全局配置:`~/.pi/agent/jev.json`
181
+ 4. 内置默认值
182
+
183
+ API Key **仅通过环境变量设置**,不会写入配置文件。钩子在每次事件中重新读取配置,改动对下一轮或下一次压缩生效。环境变量仍会覆盖通过 `/jev` 修改的文件配置。
184
+
185
+ ### 交互菜单与命令
186
+
187
+ 在交互模式的 pi 中,直接输入 `/jev` 打开设置菜单:
188
+
189
+ ```text
190
+ pi-jev
191
+ ├─ Toggle scope (global ⇄ project) 切换写入范围
192
+ ├─ General provider · model · baseUrl · disabled
193
+ ├─ Compaction 压缩阈值和 token 预算
194
+ ├─ Routing cheap · strong → 模型 → 思考等级
195
+ └─ Show resolved config 查看最终生效配置
196
+ ```
197
+
198
+ 文本命令支持动作、配置键和模型引用的补全:
199
+
200
+ ```text
201
+ /jev set provider openrouter -l
202
+ /jev get routing.cheap
203
+ /jev unset routing.strong
204
+ /jev keys
205
+ /jev path -l
206
+ ```
207
+
208
+ ### 配置文件示例
209
+
210
+ 项目的 `.pi/jev.json` 可以设置接入服务和压缩策略:
211
+
212
+ ```json
213
+ {
214
+ "provider": "openrouter",
215
+ "disabled": false,
216
+ "compaction": {
217
+ "keepThreshold": 0.5,
218
+ "preserveRecentMessages": 3,
219
+ "minReduction": 0.15
220
+ }
221
+ }
222
+ ```
223
+
224
+ 通过 `/jev` → **Routing** 添加路由目标,或将 `routing.cheap`/`routing.strong` 设置为已配置的模型引用。设置任意一个目标即可独立启用路由。
225
+
226
+ ### 终端 CLI
227
+
228
+ 从已构建的本地仓库中,CLI 可管理同一组配置文件:
229
+
230
+ ```sh
231
+ node bin/pi-jev.js config
232
+ node bin/pi-jev.js config set provider openrouter -l
233
+ node bin/pi-jev.js config get routing.cheap
234
+ node bin/pi-jev.js config unset routing.strong
235
+ node bin/pi-jev.js config keys
236
+ node bin/pi-jev.js config path -l
237
+ ```
238
+
239
+ 如果包的可执行文件已加入 `PATH`,可改用 `pi-jev config …`。`get` 读取最终生效值;`set`、`unset` 和 `path` 默认使用全局范围,添加 `-l`/`--project` 则使用项目范围。
240
+
241
+ ### Jev 接入服务
242
+
243
+ 以下是本项目代码中的默认值:
244
+
245
+ | 设置 | TypeSafe | OpenRouter |
246
+ | --- | --- | --- |
247
+ | 服务提供方 | `typesafe` | `openrouter` |
248
+ | 接口地址 | `https://api.typesafe.ai/v1/systemone` | `https://openrouter.ai/api/alpha/decisions`(alpha) |
249
+ | API Key | `TYPESAFE_API_KEY` | `OPENROUTER_API_KEY` |
250
+ | Jev 模型 | `jev-latest` | `typesafe/jev-1.13` |
251
+
252
+ 两者使用相同的 `{ model, state, questions }` → `{ answers }` 协议。Jev 判断模型与作为路由目标的 pi 模型是分别配置的。
253
+
254
+ `JEVC_API_KEY` 会覆盖所选服务的专用 Key,但本身不会选择服务提供方;使用 OpenRouter Key 时,应同时设置 `JEVC_PROVIDER=openrouter`。
255
+
256
+ ### 环境变量
257
+
258
+ 通用设置:
259
+
260
+ | 变量 | 默认值 | 用途 |
261
+ | --- | --- | --- |
262
+ | `TYPESAFE_API_KEY` | 未设置 | TypeSafe 认证 |
263
+ | `OPENROUTER_API_KEY` | 未设置 | OpenRouter 认证 |
264
+ | `JEVC_API_KEY` | 未设置 | 覆盖所选服务的 API Key |
265
+ | `JEVC_PROVIDER` | 自动检测 | `typesafe` 或 `openrouter` |
266
+ | `JEVC_MODEL` | 随接入服务选择 | Jev 模型标识 |
267
+ | `JEVC_BASE_URL` | 随接入服务选择 | Jev 接口地址 |
268
+ | `JEVC_DISABLED` | `false` | `1`、`true` 或 `yes` 跳过两项功能的钩子 |
269
+
270
+ 上下文压缩:
271
+
272
+ | 变量 | 配置键 | 默认值 |
273
+ | --- | --- | --- |
274
+ | `JEVC_KEEP_THRESHOLD` | `compaction.keepThreshold` | `0.5` |
275
+ | `JEVC_BORDERLINE` | `compaction.borderline` | `0.1` |
276
+ | `JEVC_PRESERVE_RECENT` | `compaction.preserveRecentMessages` | `3` |
277
+ | `JEVC_TRUNCATE_HEAD` | `compaction.truncateHeadChars` | `300` |
278
+ | `JEVC_MIN_REDUCTION` | `compaction.minReduction` | `0.15` |
279
+ | `JEVC_MAX_STATE_TOKENS` | `compaction.maxStateTokens` | `25000` |
280
+ | `JEVC_MAX_REQUEST_TOKENS` | `compaction.maxRequestTokens` | `30000` |
281
+
282
+ `keepThreshold` 控制原文保留阈值,`borderline` 定义阈值下方可由过时程度判断补救的区间。`preserveRecentMessages` 保护近期转换后的消息;`truncateHeadChars` 限制截断结果保留的头部字符数。`minReduction` 是替代 pi 原生摘要所需的最低估算缩减比例。两个 token 上限分别约束 Jev 上下文,以及上下文加问题的单次请求。
283
+
284
+ 模型路由:
285
+
286
+ | 变量 | 默认值 | 用途 |
287
+ | --- | --- | --- |
288
+ | `JEVC_ROUTE_CHEAP` | 未设置 | 简单请求的目标模型引用 |
289
+ | `JEVC_ROUTE_STRONG` | 未设置 | 复杂请求的目标模型引用 |
290
+ | `JEVC_ROUTE_EASY_MAX` | `0.5` | 使用低成本模型的最高难度 |
291
+ | `JEVC_ROUTE_HARD_MIN` | `1.5` | 使用强模型的最低难度 |
292
+ | `JEVC_ROUTE_MIN_CONFIDENCE` | `0.6` | 切换模型所需的最低置信度 |
293
+
294
+ 路由阈值属于配置加载器支持的高级覆盖项,不在设置菜单或 CLI 配置键列表中展示。
295
+
296
+ ## 常见问题
297
+
298
+ | 现象 | 检查方式 |
299
+ | --- | --- |
300
+ | `/jev` 不可用 | 通过 `pi config` 启用 `extensions/jev.ts`,必要时执行 `/reload` |
301
+ | 压缩仍使用 pi 原生摘要 | 检查所选服务的 API Key、`disabled`、Jev 错误和 `minReduction` |
302
+ | 路由始终不切换模型 | 至少设置一个目标,确认 pi 模型已认证,并检查难度与置信度条件 |
303
+ | 修改配置后没有生效 | 检查环境变量和项目配置,它们的优先级高于全局配置 |
304
+ | 本地 CLI 找不到 `dist/cli/main.js` | 执行 `npm run build` |
305
+
306
+ ## 开发
307
+
308
+ CI 使用 Node.js 22。在仓库根目录运行:
309
+
310
+ ```sh
311
+ npm ci
312
+ npm run typecheck
313
+ npm test
314
+ npm run build
315
+ ```
316
+
317
+ 测试使用固定样例和模拟的 `JevAsker`,不会调用 Jev API。覆盖压缩决策和消息转换、路由、配置优先级、CLI、参数补全、模型选择及菜单界面。
318
+
319
+ | 路径 | 职责 |
320
+ | --- | --- |
321
+ | [src/compaction](src/compaction) | 消息转换、Jev 请求分批、保留规则、记录渲染和压缩钩子 |
322
+ | [src/routing](src/routing) | 难度决策与模型切换 |
323
+ | [src/commands](src/commands) | `/jev`、设置菜单、参数补全和模型选择 |
324
+ | [src/cli](src/cli) | 共用配置命令引擎 |
325
+ | [src/shared/config.ts](src/shared/config.ts) | 配置分层、默认值和配置键元数据 |
326
+ | [extensions](extensions) | 扩展入口 |
327
+ | [src/vendor/fast-jev-compaction](src/vendor/fast-jev-compaction) | 内置的第三方 Jev 客户端和压缩基础模块 |
328
+ | [test](test) | 离线测试套件 |
329
+
330
+ 新增功能时,在 `src/<feature>/` 创建默认导出的扩展工厂及纯函数辅助模块,在 `extensions/` 添加薄入口,在 `src/shared/config.ts` 定义配置,并在 `test/` 覆盖行为。Jev 请求失败时必须允许 pi 继续运行。
331
+
332
+ ## 致谢
333
+
334
+ - [tamaratran/fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction):以 MIT 许可内置的 Jev 客户端和压缩基础模块,许可证随源码保留。
335
+ - [@narumitw/pi-tui-kit](https://www.npmjs.com/package/@narumitw/pi-tui-kit):提供 `/jev` 交互设置菜单。
336
+ - [TypeSafe](https://typesafe.ai):提供 Jev System One;OpenRouter 提供另一种 Decisions API 接入方式。
337
+
338
+ ## 许可证
339
+
340
+ [MIT](LICENSE)。
package/bin/pi-jev.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { runCli } from '../dist/cli/main.js';
5
+
6
+ const code = runCli(process.argv.slice(2), {
7
+ env: process.env,
8
+ globalPath: join(homedir(), '.pi', 'agent', 'jev.json'),
9
+ projectPath: join(process.cwd(), '.pi', 'jev.json'),
10
+ out: (text) => process.stdout.write(text + '\n'),
11
+ err: (text) => process.stderr.write(text + '\n'),
12
+ });
13
+ process.exit(code);
@@ -0,0 +1,223 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { CONFIG_KEYS, configFromEnv, loadConfig, mergeConfigFiles, readConfigFile, } from '../shared/config.js';
4
+ const USAGE = `pi-jev — configuration CLI for the pi-jev extension suite
5
+
6
+ Usage:
7
+ pi-jev config Show resolved config and each value's source
8
+ pi-jev config set <key> <value> Write a key (global by default)
9
+ pi-jev config get <key> Print the resolved value of a key
10
+ pi-jev config unset <key> Remove a key
11
+ pi-jev config path Print the config file path
12
+ pi-jev config keys List all keys with types and env overrides
13
+
14
+ Options:
15
+ -l, --project Target the project file (.pi/jev.json) instead of the global one
16
+
17
+ Sources resolve highest-first: environment variables > project file > global file > defaults.
18
+ API keys are environment-only and never stored in files.
19
+ Changes take effect after pi restarts or /reload.`;
20
+ export function keyMeta(path) {
21
+ return CONFIG_KEYS.find((key) => key.path === path);
22
+ }
23
+ export function getPath(file, path) {
24
+ const [section, key] = path.split('.');
25
+ if (!key)
26
+ return file[section];
27
+ return file[section]?.[key];
28
+ }
29
+ export function setPath(file, path, value) {
30
+ const [section, key] = path.split('.');
31
+ if (!key) {
32
+ file[section] = value;
33
+ return;
34
+ }
35
+ const holder = (file[section] ??= {});
36
+ holder[key] = value;
37
+ }
38
+ function deletePath(file, path) {
39
+ const [section, key] = path.split('.');
40
+ if (!key)
41
+ return delete file[section];
42
+ const holder = file[section];
43
+ if (!holder || !(key in holder))
44
+ return false;
45
+ delete holder[key];
46
+ if (Object.keys(holder).length === 0)
47
+ delete file[section];
48
+ return true;
49
+ }
50
+ export function coerce(meta, raw) {
51
+ if (meta.type === 'number') {
52
+ const value = Number(raw);
53
+ if (!Number.isFinite(value))
54
+ throw new Error(`${meta.path}: expected a number, got "${raw}"`);
55
+ return value;
56
+ }
57
+ if (meta.type === 'boolean') {
58
+ if (/^(1|true|yes)$/i.test(raw))
59
+ return true;
60
+ if (/^(0|false|no)$/i.test(raw))
61
+ return false;
62
+ throw new Error(`${meta.path}: expected a boolean, got "${raw}"`);
63
+ }
64
+ const value = raw.trim();
65
+ if (!value)
66
+ throw new Error(`${meta.path}: expected a non-empty value`);
67
+ if (meta.path === 'provider' && value !== 'typesafe' && value !== 'openrouter') {
68
+ throw new Error(`provider: expected "typesafe" or "openrouter", got "${value}"`);
69
+ }
70
+ return value;
71
+ }
72
+ export function readOrInit(path) {
73
+ try {
74
+ return readConfigFile(path);
75
+ }
76
+ catch (error) {
77
+ if (error.code === 'ENOENT')
78
+ return {};
79
+ throw new Error(`cannot read ${path}: ${error.message}`);
80
+ }
81
+ }
82
+ export function saveFile(path, file) {
83
+ mkdirSync(dirname(path), { recursive: true });
84
+ writeFileSync(path, JSON.stringify(file, null, 2) + '\n');
85
+ }
86
+ function takeProjectFlag(rest) {
87
+ const positional = rest.filter((arg) => arg !== '-l' && arg !== '--project');
88
+ return { project: positional.length !== rest.length, positional };
89
+ }
90
+ function tryReadFile(path, io) {
91
+ try {
92
+ return readConfigFile(path);
93
+ }
94
+ catch (error) {
95
+ if (error.code === 'ENOENT')
96
+ return undefined;
97
+ io.err(`pi-jev: ignoring config at ${path} (${error.message})`);
98
+ return undefined;
99
+ }
100
+ }
101
+ export function valueAt(config, path) {
102
+ const record = config;
103
+ // `compaction.*` keys are flat fields on JevConfig; `routing.*` is nested.
104
+ if (path.startsWith('compaction.'))
105
+ return record[path.slice('compaction.'.length)];
106
+ const [section, key] = path.split('.');
107
+ if (!key)
108
+ return record[section];
109
+ return record[section]?.[key];
110
+ }
111
+ export function formatValue(value) {
112
+ if (value === undefined)
113
+ return 'unset';
114
+ return String(value);
115
+ }
116
+ function show(io) {
117
+ const global = tryReadFile(io.globalPath, io);
118
+ const project = tryReadFile(io.projectPath, io);
119
+ const config = configFromEnv(io.env, mergeConfigFiles(global, project));
120
+ const width = Math.max(...CONFIG_KEYS.map((key) => key.path.length));
121
+ for (const meta of CONFIG_KEYS) {
122
+ const source = io.env[meta.env ?? '']?.trim()
123
+ ? 'env'
124
+ : getPath(project ?? {}, meta.path) !== undefined
125
+ ? 'project'
126
+ : getPath(global ?? {}, meta.path) !== undefined
127
+ ? 'global'
128
+ : 'default';
129
+ io.out(`${meta.path.padEnd(width)} = ${formatValue(valueAt(config, meta.path)).padEnd(28)} # ${source}`);
130
+ }
131
+ io.out(`${'apiKey'.padEnd(width)} = ${config.apiKey ? 'set' : 'unset'}${''.padEnd(23)} # env only`);
132
+ if (!config.routing.cheap && !config.routing.strong) {
133
+ io.err('# routing is disabled: set routing.cheap (or routing.strong) to enable it');
134
+ }
135
+ return 0;
136
+ }
137
+ /** Runs the CLI against injectable IO. Returns the process exit code. */
138
+ export function runCli(argv, io) {
139
+ const [cmd, sub, ...rest] = argv;
140
+ if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
141
+ io.out(USAGE);
142
+ return 0;
143
+ }
144
+ if (cmd !== 'config') {
145
+ io.err(`unknown command "${cmd}"`);
146
+ io.err(USAGE);
147
+ return 1;
148
+ }
149
+ try {
150
+ if (sub === undefined)
151
+ return show(io);
152
+ // Actions: `pi-jev config set|get|unset <key> [value] [-l]`
153
+ if (sub === 'set') {
154
+ const { project, positional } = takeProjectFlag(rest);
155
+ const [keyPath, ...valueParts] = positional;
156
+ const meta = keyMeta(keyPath ?? '');
157
+ if (!meta) {
158
+ io.err(`unknown key "${keyPath}" (API keys are env-only). Run 'pi-jev config keys' to list valid keys.`);
159
+ return 1;
160
+ }
161
+ const raw = valueParts.join(' ');
162
+ if (!raw.trim()) {
163
+ io.err(`usage: pi-jev config set ${meta.path} <value>`);
164
+ return 1;
165
+ }
166
+ const value = coerce(meta, raw);
167
+ const targetPath = project ? io.projectPath : io.globalPath;
168
+ const file = readOrInit(targetPath);
169
+ setPath(file, meta.path, value);
170
+ saveFile(targetPath, file);
171
+ io.out(`set ${meta.path} = ${String(value)} (${project ? 'project' : 'global'}: ${targetPath})`);
172
+ return 0;
173
+ }
174
+ if (sub === 'get' || sub === 'unset') {
175
+ const { project, positional } = takeProjectFlag(rest);
176
+ const meta = keyMeta(positional[0] ?? '');
177
+ if (!meta) {
178
+ io.err(`unknown key "${positional[0]}" (API keys are env-only). Run 'pi-jev config keys' to list valid keys.`);
179
+ return 1;
180
+ }
181
+ const targetPath = project ? io.projectPath : io.globalPath;
182
+ if (sub === 'get') {
183
+ const config = loadConfig({ env: io.env, globalPath: io.globalPath, projectPath: io.projectPath, warn: io.err });
184
+ io.out(formatValue(valueAt(config, meta.path)));
185
+ return 0;
186
+ }
187
+ const file = readOrInit(targetPath);
188
+ if (deletePath(file, meta.path)) {
189
+ saveFile(targetPath, file);
190
+ io.out(`unset ${meta.path} (${project ? 'project' : 'global'}: ${targetPath})`);
191
+ }
192
+ else {
193
+ io.out(`${meta.path} is not set (${project ? 'project' : 'global'})`);
194
+ }
195
+ return 0;
196
+ }
197
+ if (sub === 'path') {
198
+ const { project } = takeProjectFlag(rest);
199
+ io.out(project ? io.projectPath : io.globalPath);
200
+ return 0;
201
+ }
202
+ if (sub === 'keys') {
203
+ const width = Math.max(...CONFIG_KEYS.map((key) => key.path.length));
204
+ for (const meta of CONFIG_KEYS) {
205
+ io.out(`${meta.path.padEnd(width)} ${meta.type.padEnd(7)} ${meta.env ? `env: ${meta.env.padEnd(26)} ` : ''}${meta.description}`);
206
+ }
207
+ return 0;
208
+ }
209
+ // Shorthand: `pi-jev config <key>` = get
210
+ const meta = keyMeta(sub);
211
+ if (!meta) {
212
+ io.err(`unknown key "${sub}" (API keys are env-only). Run 'pi-jev config keys' to list valid keys.`);
213
+ return 1;
214
+ }
215
+ const config = loadConfig({ env: io.env, globalPath: io.globalPath, projectPath: io.projectPath, warn: io.err });
216
+ io.out(formatValue(valueAt(config, meta.path)));
217
+ return 0;
218
+ }
219
+ catch (error) {
220
+ io.err(`pi-jev: ${error.message}`);
221
+ return 1;
222
+ }
223
+ }
@@ -0,0 +1,87 @@
1
+ import { CONFIG_KEYS } from '../shared/config.js';
2
+ const ACTIONS = [
3
+ { value: 'set', label: 'set', description: 'write a key (add -l for the project file)' },
4
+ { value: 'get', label: 'get', description: 'print the resolved value of a key' },
5
+ { value: 'unset', label: 'unset', description: 'remove a key' },
6
+ { value: 'keys', label: 'keys', description: 'list all keys with types and env overrides' },
7
+ { value: 'path', label: 'path', description: 'print the config file path' },
8
+ ];
9
+ const FLAG_RE = /^(-l|--project)$/;
10
+ function filterItems(items, prefix) {
11
+ const normalized = prefix.trim().toLowerCase();
12
+ const matches = items.filter((item) => item.value.toLowerCase().startsWith(normalized));
13
+ return matches.length > 0 ? matches : null;
14
+ }
15
+ function keyItems(action) {
16
+ return CONFIG_KEYS.map((meta) => ({
17
+ value: `${action} ${meta.path}`,
18
+ label: meta.path,
19
+ description: meta.description,
20
+ }));
21
+ }
22
+ /** Enum-valued keys offer their options; free-form keys get no value completion. */
23
+ export const VALUE_OPTIONS = {
24
+ provider: [
25
+ { label: 'openrouter', description: 'OpenRouter Decisions API' },
26
+ { label: 'typesafe', description: 'TypeSafe System One' },
27
+ ],
28
+ disabled: [
29
+ { label: 'true', description: 'bypass all pi-jev hooks' },
30
+ { label: 'false', description: 'enable pi-jev hooks' },
31
+ ],
32
+ };
33
+ /**
34
+ * Completes the argument text after `/jev `. Flags are position-neutral; the
35
+ * last non-flag token in progress decides what is offered.
36
+ */
37
+ /** Model refs for cheap/strong value completion; empty when no snapshot is available. */
38
+ const modelOptions = [];
39
+ /** Refreshes the model snapshot (called on session_start). */
40
+ export function setModelOptions(options) {
41
+ modelOptions.length = 0;
42
+ modelOptions.push(...options);
43
+ }
44
+ export function completeJevArguments(argText) {
45
+ const endsWithSpace = /\s$/.test(argText);
46
+ const tokens = argText.trim().split(/\s+/).filter(Boolean);
47
+ const prior = (endsWithSpace ? tokens : tokens.slice(0, -1)).filter((token) => !FLAG_RE.test(token));
48
+ const partial = endsWithSpace || tokens.length === 0 ? '' : tokens[tokens.length - 1];
49
+ // Completing the action itself.
50
+ if (prior.length === 0 && !endsWithSpace) {
51
+ return filterItems(ACTIONS, partial);
52
+ }
53
+ if (prior.length === 0)
54
+ return ACTIONS; // space typed, nothing yet: offer the menu
55
+ const action = prior[0];
56
+ const flags = argText.trim().split(/\s+/).some((token) => FLAG_RE.test(token));
57
+ const suffix = flags ? ' -l' : '';
58
+ if (action === 'set' || action === 'get' || action === 'unset') {
59
+ if (prior.length === 1) {
60
+ const items = keyItems(action + suffix);
61
+ return filterItems(items, `${action}${suffix} ${partial}`.trim());
62
+ }
63
+ if (action === 'set' && prior.length === 2) {
64
+ const key = prior[1];
65
+ if (key === 'routing.cheap' || key === 'routing.strong') {
66
+ if (modelOptions.length === 0)
67
+ return null;
68
+ const items = modelOptions.map((model) => ({
69
+ value: `set ${key} ${model.ref}${suffix}`,
70
+ label: model.name,
71
+ description: `${model.provider}${model.image ? ' · images' : ''}${model.reasoning ? ' · thinking' : ''}`,
72
+ }));
73
+ return filterItems(items, `set ${key}${suffix} ${partial}`.trim());
74
+ }
75
+ const options = VALUE_OPTIONS[key] ?? [];
76
+ if (options.length === 0)
77
+ return null;
78
+ const items = options.map((option) => ({
79
+ value: `set ${key} ${option.label}${suffix}`,
80
+ label: option.label,
81
+ description: option.description,
82
+ }));
83
+ return filterItems(items, `set ${key}${suffix} ${partial}`.trim());
84
+ }
85
+ }
86
+ return null;
87
+ }