@moonhalf383/pi-whoasked 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +162 -2
- package/README.zh-CN.md +161 -0
- package/configs/en.strict.json +60 -0
- package/configs/zh-CN.strict.json +54 -0
- package/docs/english-patterns.md +27 -0
- package/example.png +0 -0
- package/package.json +27 -4
- package/src/config.ts +131 -0
- package/src/defaults.ts +25 -0
- package/src/extension.ts +199 -0
- package/src/index.ts +7 -0
- package/whoasked.example.json +25 -0
- package/whoasked.jpg +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-whoasked contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,163 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @moonhalf383/pi-whoasked
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
A Pi extension that watches streaming assistant replies for configurable regex patterns. On a match, it interrupts generation and sends the rule's correction prompt, asking the model to answer again from the beginning.
|
|
8
|
+
|
|
9
|
+
The interrupted reply stays visible, is marked as superseded, and is followed by a new answer. Text already displayed is not retracted.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Requires Pi (`@earendil-works/pi-coding-agent`) 1.4.0+ and Node.js 22.19+. Node.js 24 is recommended for running tests.
|
|
14
|
+
|
|
15
|
+
Install from npm:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@moonhalf383/pi-whoasked
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Restart Pi or run `/reload`. Bilingual rules are enabled out of the box.
|
|
22
|
+
|
|
23
|
+
Alternatively, install from GitHub:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install git:github.com/Moonhalf383/pi-whoasked
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For local development, clone the repository and load the extension temporarily:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/Moonhalf383/pi-whoasked.git
|
|
33
|
+
cd pi-whoasked
|
|
34
|
+
pi -e ./src/index.ts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use `pi install .` from the repository directory for a persistent local installation.
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
Chinese and English rules are enabled by default: all eight groups from the Chinese strict preset plus nine English groups. Notifications, status text, configuration errors, and correction-message framing use English. Default rule names and correction prompts are also in English; prompts ask the model to keep the user's requested reply language. Custom rule names and prompts are displayed as configured.
|
|
42
|
+
|
|
43
|
+
Configuration is merged in this order, with later values taking precedence:
|
|
44
|
+
|
|
45
|
+
1. Built-in defaults.
|
|
46
|
+
2. `<Pi agentDir>/whoasked.json`, usually `~/.pi/agent/whoasked.json`.
|
|
47
|
+
3. `<cwd>/.pi/whoasked.json`, only in a project trusted by Pi.
|
|
48
|
+
|
|
49
|
+
`enabled` and `maxRetries` override previous values. A `rules` array replaces the entire previous rule set; `rules: []` disables all rules. Untrusted project configuration is not read.
|
|
50
|
+
|
|
51
|
+
You can also specify a standalone file. It is applied over the built-in defaults without loading personal or project configuration:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pi --whoasked-config /path/to/whoasked.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The extension must already be installed. When loading directly from a checkout, add `-e ./src/index.ts`.
|
|
58
|
+
|
|
59
|
+
No configuration file is needed to use the bilingual defaults. To keep the defaults while adjusting the retry limit, use `{ "maxRetries": 4 }`. A file containing `rules` replaces all built-in rules, including English rules; remove that field to inherit future defaults.
|
|
60
|
+
|
|
61
|
+
See [whoasked.example.json](whoasked.example.json) for a smaller custom Chinese rule set. A minimal English-only override:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"enabled": true,
|
|
66
|
+
"maxRetries": 2,
|
|
67
|
+
"rules": [
|
|
68
|
+
{
|
|
69
|
+
"name": "Unprompted defensive disclaimer",
|
|
70
|
+
"pattern": "cannot claim",
|
|
71
|
+
"flags": "iu",
|
|
72
|
+
"correctionPrompt": "Answer the original question again. Remove unprompted defensive disclaimers. State the relevant facts and limitations directly, and do not mention this correction."
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Configuration fields:
|
|
79
|
+
|
|
80
|
+
- `enabled`: enable detection; defaults to `true`.
|
|
81
|
+
- `maxRetries`: maximum automatic corrections per user request; defaults to `2`. Set to `0` to report matches without interrupting.
|
|
82
|
+
- `rules[].name`: a unique, nonempty rule name.
|
|
83
|
+
- `rules[].pattern`: JavaScript regex source without `/…/` delimiters. Escape backslashes in JSON.
|
|
84
|
+
- `rules[].flags`: regex flags; defaults to `u`. An empty string omits the Unicode flag.
|
|
85
|
+
- `rules[].correctionPrompt`: a nonempty correction instruction sent to the model on a match.
|
|
86
|
+
- `rules[].enabled`: enable this rule; defaults to `true`.
|
|
87
|
+
|
|
88
|
+
Rules are checked in configuration order; the first matching rule in a snapshot wins. Match state is reset for `g`; `y` retains its sticky semantics. Patterns that match empty input are rejected, and zero-width matches encountered at runtime are skipped. Unknown fields, invalid regexes, or invalid values produce an error and disable detection. Fix the configuration and run `/whoasked reload`.
|
|
89
|
+
|
|
90
|
+
For single-language overrides, use [configs/zh-CN.strict.json](configs/zh-CN.strict.json) (Chinese names and prompts) or [configs/en.strict.json](configs/en.strict.json) (English). These are subsets of the bilingual defaults, not additional detection modes. From a repository checkout:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pi -e ./src/index.ts --whoasked-config ./configs/zh-CN.strict.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both languages cover contrasts, repackaging accusations, evidence qualifiers, replacement disclaimers, exclusive causality, action exclusions, hypothetical implications, and broad negation. English adds formulaic caveats. Common English forms include negative-positive contrasts, implication disclaimers, replacement warnings, and stock cautionary introductions. Matching is case-insensitive for English and supports straight and curly apostrophes. See [English pattern notes](docs/english-patterns.md) for examples and user reports that informed the rules.
|
|
97
|
+
|
|
98
|
+
The defaults are aggressive lexical filters. Ordinary negation, comparisons, quotations, and code examples can trigger corrections. Specific groups precede broad fallback rules, but a short streaming prefix can trigger the fallback before a more specific phrase arrives. Correction prompts preserve facts, important risks, and necessary limitations. Narrow or replace rules to reduce false positives.
|
|
99
|
+
|
|
100
|
+
After updating the extension, run `/reload` to load the new code. `/whoasked reload` only reloads configuration files.
|
|
101
|
+
|
|
102
|
+
## Example
|
|
103
|
+
|
|
104
|
+

|
|
105
|
+
|
|
106
|
+
The screenshot shows an earlier Chinese UI with a personal retry limit of 20. The current default UI is English and the built-in retry limit is 2.
|
|
107
|
+
|
|
108
|
+
Corrections appear as custom messages in the conversation. The original question, existing tool results, and interrupted message history are retained; the session tree is not rolled back.
|
|
109
|
+
|
|
110
|
+
## Commands
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
/whoasked Show status, configuration sources, and enabled rules
|
|
114
|
+
/whoasked status Same as above
|
|
115
|
+
/whoasked on Reload configuration and enable detection for this session
|
|
116
|
+
/whoasked off Disable detection and cancel any pending correction
|
|
117
|
+
/whoasked reload Reload configuration
|
|
118
|
+
/whoasked cancel Cancel further corrections for this request; resume on the next user input
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Commands do not write configuration files. Configuration is reloaded on session start, session switch, or extension reload.
|
|
122
|
+
|
|
123
|
+
## Streaming and cancellation
|
|
124
|
+
|
|
125
|
+
- Detection scans the full accumulated assistant text, so patterns can span streaming chunks. Separate text blocks are joined with newlines.
|
|
126
|
+
- Only assistant text is checked, not thinking, tool arguments, tool results, or user messages.
|
|
127
|
+
- A match interrupts generation. The extension waits for the run to settle before starting a new answer.
|
|
128
|
+
- If you press Escape after the extension has already requested an abort, Pi's public events cannot distinguish that additional stop during the same shutdown window. Use `/whoasked cancel` or `/whoasked off` to explicitly cancel the pending correction.
|
|
129
|
+
- New input and already queued input take priority over corrections.
|
|
130
|
+
- After `maxRetries` is reached, further matches produce a warning and the current answer continues without interruption.
|
|
131
|
+
- Detection and retries also run in JSON/RPC/print modes. Stream consumers still receive previously emitted text and should use message boundaries to distinguish old replies from retries. Status and notification visibility depends on the host interface.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm ci --ignore-scripts
|
|
137
|
+
npm run check
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`check` runs TypeScript checks, configuration and streaming unit tests, and real Pi SDK lifecycle tests. SDK tests use a local simulated model stream, make no online model calls, and do not read user credentials.
|
|
141
|
+
|
|
142
|
+
This package uses Pi's explicit `pi.extensions` manifest to load `src/index.ts`. An `extensions/` directory and compiled JavaScript output are optional; Pi loads the TypeScript entry directly. The Pi host is declared as a peer dependency and is excluded from the archive. Pi 1.4.0+ is required by the extension API.
|
|
143
|
+
|
|
144
|
+
Prepare and verify a local release archive without publishing:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm pack
|
|
148
|
+
npm run pack:check
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`npm pack` runs `check` first through `prepack`. `pack:check` installs the archive in an isolated temporary directory, checks its contents, and verifies Pi package discovery and bilingual startup without calling a model. The resulting file is `moonhalf383-pi-whoasked-0.1.0.tgz`. Scoped npm packages are configured for public access; these commands only create and test a local archive.
|
|
152
|
+
|
|
153
|
+
Project files:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
src/index.ts Pi extension entry point
|
|
157
|
+
src/config.ts Configuration loading and regex validation
|
|
158
|
+
src/defaults.ts Bilingual default rule assembly
|
|
159
|
+
configs/ Chinese and English rule presets
|
|
160
|
+
src/extension.ts Streaming detection, interruption, and retries
|
|
161
|
+
whoasked.example.json Editable configuration example
|
|
162
|
+
tests/ Unit and SDK integration tests
|
|
163
|
+
```
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# @moonhalf383/pi-whoasked
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
一个 Pi 扩展,用可配置正则检测助手流式回答中的表述,命中后中断,发送对应的校正 prompt,要求模型从头重新回答。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
需要 Pi(`@earendil-works/pi-coding-agent`)1.4.0+ 和 Node.js 22.19+。运行测试推荐 Node.js 24。
|
|
12
|
+
|
|
13
|
+
通过 npm 安装:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:@moonhalf383/pi-whoasked
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
重启 Pi 或执行 `/reload`,即可使用内置双语规则。
|
|
20
|
+
|
|
21
|
+
也可以从 GitHub 安装:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install git:github.com/Moonhalf383/pi-whoasked
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
本地开发时,克隆仓库并临时加载:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/Moonhalf383/pi-whoasked.git
|
|
31
|
+
cd pi-whoasked
|
|
32
|
+
pi -e ./src/index.ts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
如需持久加载本地副本,在仓库目录执行 `pi install .`。
|
|
36
|
+
|
|
37
|
+
## 配置
|
|
38
|
+
|
|
39
|
+
默认同时启用中文和英文规则:原有中文严格预设的 8 组规则,以及 9 组英文规则。通知、状态、配置错误和校正消息框架统一使用英文;默认规则名称与校正 prompt 也使用英文,指令要求保留用户指定的回答语言。自定义规则名称和 prompt 按配置原样显示。
|
|
40
|
+
|
|
41
|
+
配置按以下顺序合并,后者优先:
|
|
42
|
+
|
|
43
|
+
1. 内置默认值。
|
|
44
|
+
2. `<Pi agentDir>/whoasked.json`,通常为 `~/.pi/agent/whoasked.json`。
|
|
45
|
+
3. 已获 Pi 项目信任的 `<cwd>/.pi/whoasked.json`。
|
|
46
|
+
|
|
47
|
+
`enabled` 和 `maxRetries` 覆盖前值;`rules` 数组整体替换前面的规则。`rules: []` 表示不启用任何规则。未获信任的项目配置不会读取。
|
|
48
|
+
|
|
49
|
+
也可以指定独立配置文件,此时只在内置默认值之上应用该文件,不再读取个人和项目配置:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pi --whoasked-config /path/to/whoasked.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
此命令要求已安装插件;从源码临时加载时,加上 `-e ./src/index.ts`。
|
|
56
|
+
|
|
57
|
+
无需配置文件即可使用双语默认规则。仅调整重试上限时可写 `{ "maxRetries": 4 }`;已有文件中的 `rules` 会替换全部默认规则,包括英文规则。删除该字段即可跟随后续默认值。
|
|
58
|
+
|
|
59
|
+
[whoasked.example.json](whoasked.example.json) 是较小的自定义中文规则示例。最小自定义配置:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"enabled": true,
|
|
64
|
+
"maxRetries": 2,
|
|
65
|
+
"rules": [
|
|
66
|
+
{
|
|
67
|
+
"name": "无端引入冒充关系",
|
|
68
|
+
"pattern": "不将[^。!?\\n]{1,40}冒充为",
|
|
69
|
+
"flags": "u",
|
|
70
|
+
"correctionPrompt": "请重新回答原问题。删除无端引入的‘冒充’指控或防御性声明,直接陈述事实及相关局限。不要提及本次校正。"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
配置字段:
|
|
77
|
+
|
|
78
|
+
- `enabled`:是否启用检测,默认 `true`。
|
|
79
|
+
- `maxRetries`:每次用户请求最多自动校正次数,默认 `2`;`0` 只提示命中,不中断。
|
|
80
|
+
- `rules[].name`:唯一的非空规则名称。
|
|
81
|
+
- `rules[].pattern`:JavaScript 正则源文本,无 `/…/` 分隔符;JSON 中反斜杠需要转义。
|
|
82
|
+
- `rules[].flags`:正则 flags,默认 `u`;空字符串表示不加 Unicode flag。
|
|
83
|
+
- `rules[].correctionPrompt`:命中后发送给模型的非空校正要求。
|
|
84
|
+
- `rules[].enabled`:是否启用这条规则,默认 `true`。
|
|
85
|
+
|
|
86
|
+
规则按配置顺序检查,同一快照多条命中时采用第一条。`g` 的匹配状态会重置,`y` 保留其粘连匹配语义。可匹配空输入的规则会被拒绝,运行中零宽命中会被跳过。未知字段、无效正则和非法值会报错并关闭检测,修复后执行 `/whoasked reload`。
|
|
87
|
+
|
|
88
|
+
如需仅检测一种语言,可使用 [configs/zh-CN.strict.json](configs/zh-CN.strict.json)(中文名称和 prompt)或 [configs/en.strict.json](configs/en.strict.json)(英文)。它们是双语默认规则的子集。在源码目录中运行:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pi -e ./src/index.ts --whoasked-config ./configs/zh-CN.strict.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
两种语言都覆盖虚假对立、包装与扩大主张、证据限定、替代关系、单一因果、行动排除、假想推论和宽泛否定;英文另加模板化 caveat 规则。英文匹配支持大小写变化、直引号与弯引号缩写。具体表达及参考讨论见 [英文规则说明](docs/english-patterns.md)。
|
|
95
|
+
|
|
96
|
+
默认使用激进的词面检测,正常否定、比较、引文和代码示例也可能触发。具体规则排在兜底规则之前;流式前缀可能先触发兜底,再到达具体短语。校正 prompt 要求保留事实、必要限制与重要风险。可通过收窄或替换规则降低误报。
|
|
97
|
+
|
|
98
|
+
升级插件后执行 `/reload` 加载新代码;`/whoasked reload` 仅重读配置文件。
|
|
99
|
+
|
|
100
|
+
## 运行效果
|
|
101
|
+
|
|
102
|
+

|
|
103
|
+
|
|
104
|
+
截图使用个人配置的 20 次重试上限;当前默认界面为英文,内置重试上限为 2。
|
|
105
|
+
|
|
106
|
+
校正作为 custom message 显示在对话里。原用户问题、已有工具结果和被中断消息的会话记录均保留,不回滚会话树。
|
|
107
|
+
|
|
108
|
+
## 命令
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
/whoasked 查看状态、配置来源和启用规则
|
|
112
|
+
/whoasked status 同上
|
|
113
|
+
/whoasked on 重新读取配置并在当前会话开启检测
|
|
114
|
+
/whoasked off 关闭检测并取消待执行校正
|
|
115
|
+
/whoasked reload 重新读取配置
|
|
116
|
+
/whoasked cancel 取消本次请求的后续校正,下次用户输入恢复检测
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
命令不写回配置文件。会话启动、切换或扩展重载时重新读取配置。
|
|
120
|
+
|
|
121
|
+
## 流式与停止行为
|
|
122
|
+
|
|
123
|
+
- 检查当前助手消息的完整累计正文,可以匹配横跨多个流式片段的模式。不同 text block 之间用换行连接。
|
|
124
|
+
- 只检查助手 text,不检查 thinking、工具参数、工具结果、用户消息。
|
|
125
|
+
- 命中后中断当前生成,等待本轮完全结束,再触发重新回答。
|
|
126
|
+
- 若插件已触发中断,你在同一收尾窗口又按 Escape,Pi 的公共事件无法区分这次额外停止。可用 `/whoasked cancel` 或 `/whoasked off` 显式取消后续校正。
|
|
127
|
+
- 新输入、已有待处理输入优先于校正。
|
|
128
|
+
- 达到 `maxRetries` 后若仍命中,提示达到上限,让本次回答继续显示,不再中断。
|
|
129
|
+
- JSON/RPC/print 模式也运行检测和重试。流式消费者仍会收到已输出的片段,需要按消息边界区分旧回答与重答;状态栏和通知是否可见取决于宿主界面。
|
|
130
|
+
|
|
131
|
+
## 开发
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npm ci --ignore-scripts
|
|
135
|
+
npm run check
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`check` 包含 TypeScript 检查、配置与流式检测单元测试,以及真实 Pi SDK 生命周期测试。SDK 测试使用本地模拟模型流,不调用在线模型,不读取用户凭据。
|
|
139
|
+
|
|
140
|
+
本包通过 Pi 的 `pi.extensions` 显式清单加载 `src/index.ts`。`extensions/` 目录和编译后的 JavaScript 均为可选项,Pi 直接加载 TypeScript 入口。Pi 宿主声明为 peer dependency,压缩包不包含宿主副本;扩展 API 要求 Pi 1.4.0+。
|
|
141
|
+
|
|
142
|
+
本地准备并验证发布包:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm pack
|
|
146
|
+
npm run pack:check
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`npm pack` 会通过 `prepack` 先执行 `check`。`pack:check` 在独立临时目录中安装压缩包,检查内容,并验证 Pi 包发现与双语启动,全程使用本地文件。产物为 `moonhalf383-pi-whoasked-0.1.0.tgz`。已配置 scoped npm 包的公开访问权限,以上命令仅本地打包与验证。
|
|
150
|
+
|
|
151
|
+
文件结构:
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
src/index.ts Pi 扩展入口
|
|
155
|
+
src/config.ts 配置加载与正则校验
|
|
156
|
+
src/defaults.ts 双语默认规则组装
|
|
157
|
+
configs/ 中文及英文规则预设
|
|
158
|
+
src/extension.ts 流式检测、中断和重试生命周期
|
|
159
|
+
whoasked.example.json 可编辑的配置示例
|
|
160
|
+
tests/ 单元测试与 SDK 集成测试
|
|
161
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"maxRetries": 2,
|
|
4
|
+
"rules": [
|
|
5
|
+
{
|
|
6
|
+
"name": "Phantom contrasts",
|
|
7
|
+
"pattern": "\\b(?:rather\\s+than|instead\\s+of)\\b|\\bnot\\b[^.!?\\r\\n]{1,160}?\\bbut\\b|\\b(?:(?:it|this|that|you)(?:['’](?:s|re)|\\s+(?:is|are))\\s+not|(?:it|this|that|you)\\s+(?:isn|aren)['’]t)\\b[^.!?\\r\\n]{1,160}?(?:[,;:—–-]\\s*|\\.\\s*)(?:it|this|that|you)(?:['’](?:s|re)|\\s+(?:is|are))\\b",
|
|
8
|
+
"flags": "iu",
|
|
9
|
+
"correctionPrompt": "Answer the original question again in its original language. State your point directly and affirmatively. Remove formulaic negative-positive contrasts and unrelated alternatives that the user never raised. Preserve meaningful distinctions by explaining the concepts and applicable conditions directly. Keep the user's original scope and do not mention this correction."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "Unsupported accusations of repackaging",
|
|
13
|
+
"pattern": "\\b(?:repackag(?:e|es|ed|ing)|reinvent(?:s|ed|ing)?\\s+the\\s+wheel|pass(?:ing)?\\s+off|masquerad(?:e|es|ed|ing)\\s+as)\\b",
|
|
14
|
+
"flags": "iu",
|
|
15
|
+
"correctionPrompt": "Answer the original question again in its original language. Describe the actual approach, changes, and results. Remove unsupported accusations of repackaging, passing something off, or unnecessary reinvention. Preserve the user's stated claim strength and scope. If duplication or exaggeration genuinely matters, give concrete evidence and practical consequences. Avoid reproducing the accusation with synonyms and do not mention this correction."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "Defensive evidence qualifiers",
|
|
19
|
+
"pattern": "\\b(?:cannot|can['’]t|must\\s+not|should\\s+not|shouldn['’]t|do\\s+not|don['’]t)\\s+(?:claim|assume|conclude|infer|assert|presume)\\b|\\b(?:not\\s+claiming|without\\s+(?:claiming|assuming))\\b",
|
|
20
|
+
"flags": "iu",
|
|
21
|
+
"correctionPrompt": "Answer the original question again in its original language. State what the evidence supports and the conditions under which the conclusion applies. Remove warnings about claims, assumptions, or inferences the user never made. Preserve qualifiers that materially change the conclusion, evidence evaluation, or recommended action, explaining the specific gap and its consequences. Do not merely substitute another disclaimer and do not mention this correction."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "Unsolicited replacement disclaimers",
|
|
25
|
+
"pattern": "\\b(?:not\\s+(?:a\\s+)?(?:substitute|replacement)\\s+for|(?:cannot|can['’]t|does\\s+not|doesn['’]t)\\s+replace)\\b",
|
|
26
|
+
"flags": "iu",
|
|
27
|
+
"correctionPrompt": "Answer the original question again in its original language. Describe the actual use, applicable conditions, and any concrete next steps. Discuss replacement only when the user asked about it or it materially changes the recommendation. Retain important limitations and risks as specific conditions and actionable guidance. Remove generic replacement disclaimers and do not mention this correction."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "Invented exclusive causality",
|
|
31
|
+
"pattern": "\\b(?:the\\s+)?(?:only|sole|single)\\s+(?:reason|cause|factor)\\b|\\bcorrelation\\s+(?:does\\s+not|doesn['’]t)\\s+(?:necessarily\\s+)?imply\\s+causation\\b",
|
|
32
|
+
"flags": "iu",
|
|
33
|
+
"correctionPrompt": "Answer the original question again in its original language. Preserve the strength and scope of the user's causal claim. Describe observed associations, plausible mechanisms, and supported conclusions. Remove rebuttals to exclusive causation when the user never asserted it. When causal identification is actually at issue, explain the evidence's resolving power and relevant controls. Keep important uncertainty without inventing an opposing position, and do not mention this correction."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "Unsolicited action exclusions",
|
|
37
|
+
"pattern": "\\b(?:cannot\\s+yet|can['’]t\\s+yet|should\\s+not\\s+continue|shouldn['’]t\\s+continue|(?:will\\s+not|won['’]t)\\s+(?:engage|participate|proceed|continue)|no\\s+longer)\\b",
|
|
38
|
+
"flags": "iu",
|
|
39
|
+
"correctionPrompt": "Answer the original question again in its original language. Explain the actual scope, feasible conclusions, and next steps. Remove unsolicited declarations of withdrawal, exclusion, or prohibition. Respect the user's explicit stop requests and boundaries. Where a genuine capability limit, failure condition, or safety constraint matters, describe the concrete reason, effect, and feasible next action. Preserve facts and important constraints, and do not mention this correction."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "Hypothetical implications",
|
|
43
|
+
"pattern": "\\b(?:this|that|it)\\s+(?:does\\s+not|doesn['’]t)\\s+(?:necessarily\\s+)?(?:mean|imply|prove)\\b|\\b(?:not|isn['’]t)\\s+(?:necessarily\\s+)?(?:the\\s+same\\s+as|equivalent\\s+to)\\b",
|
|
44
|
+
"flags": "iu",
|
|
45
|
+
"correctionPrompt": "Answer the original question again in its original language. Address what the user actually said. Remove clarifications aimed at implications or stronger interpretations that were never asserted. If a distinction materially affects the conclusion, explain the relevant concepts, conditions, and evidence directly. Correct genuine errors with specific reasons, and do not mention this correction."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "Formulaic caveats",
|
|
49
|
+
"pattern": "\\b(?:one\\s+(?:important\\s+)?(?:caveat|distinction)|one\\s+thing\\s+i(?:['’]d|\\s+would)\\s+caution\\s+against|it(?:['’]s|\\s+is)\\s+important\\s+to\\s+(?:note|remember)|to\\s+be\\s+clear)\\b",
|
|
50
|
+
"flags": "iu",
|
|
51
|
+
"correctionPrompt": "Answer the original question again in its original language. Remove stock cautionary introductions and repetitive clarifications. Include a qualification only when it materially changes the conclusion, evaluation of evidence, or action recommendation, and state that concrete information directly. Preserve important uncertainty, risks, and substantive disagreement without adding imaginary objections. Do not mention this correction."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "Broad negation fallback",
|
|
55
|
+
"pattern": "\\b(?:cannot|can['’]t|must\\s+not|mustn['’]t)\\b",
|
|
56
|
+
"flags": "iu",
|
|
57
|
+
"correctionPrompt": "Answer the original question again in its original language. Use natural, specific, direct wording. Remove unrelated prohibitions and defensive statements aimed at positions or motives the user never expressed. Preserve factual corrections, important safety risks, genuine capability limits, and explicit user constraints; explain concrete conditions, practical effects, and feasible next steps. State the scope of evidence directly. Do not turn the same invented opposition into synonyms, and do not mention this correction."
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"maxRetries": 2,
|
|
4
|
+
"rules": [
|
|
5
|
+
{
|
|
6
|
+
"name": "虚假对立与无关对照",
|
|
7
|
+
"pattern": "而不是|不是[^。!?\\r\\n]{0,160}而是",
|
|
8
|
+
"flags": "u",
|
|
9
|
+
"correctionPrompt": "请从头重答。直接回答用户实际提出的问题,优先用肯定式陈述观点和理由。去掉无必要的‘不是A而是B’和句尾追加的无关对照;不要先虚构一个用户没有提出的立场,再去否定它。如果概念差异确实影响结论,直接解释各自含义与适用条件。保留原问题的范围,不提及本次校正。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "无端指控包装与扩大主张",
|
|
13
|
+
"pattern": "不把|不将[^。!?\\r\\n]{1,40}冒充为|重新包装|扩张为|重新发明",
|
|
14
|
+
"flags": "u",
|
|
15
|
+
"correctionPrompt": "请从头重答。严格按用户原话的范围回应,直接说明实际做法、变化和结果。删除没有上下文依据的冒充、包装、扩大或重新发明之类指控,以及为自证清白而追加的声明。用户没有提出更强主张时,不替用户扩写一个主张再纠正。确有夸大或重复实现的问题时,给出具体证据和实际影响。不要只换同义词保留原有指责结构,不提及本次校正。"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "防御性证据与宣称限定",
|
|
19
|
+
"pattern": "不据此|不宣称|不预设|不得声称|不能声称",
|
|
20
|
+
"flags": "u",
|
|
21
|
+
"correctionPrompt": "请从头重答。直接说明已有证据支持什么、结论适用的条件是什么。删除针对用户从未作出的推断、宣称或预设的防御性提醒;不要擅自扩大用户判断后再反驳。只保留会实质改变证据评价、结论或行动建议的限定,具体指出证据缺口及其影响。不要把本次措辞校正改写成另一段免责声明,不提及本次校正。"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "无关替代关系声明",
|
|
25
|
+
"pattern": "不能替代",
|
|
26
|
+
"flags": "u",
|
|
27
|
+
"correctionPrompt": "请从头重答。直接描述当前方法或结果的用途、适用条件及所需补充。仅在用户实际询问替代关系,或替代关系会实质影响行动建议时讨论两者关系;省略顺手追加的泛泛替代免责声明。相关的重要限制和风险请用具体条件与建议表达,保留事实,不提及本次校正。"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "凭空引入单一因果主张",
|
|
31
|
+
"pattern": "唯一原因",
|
|
32
|
+
"flags": "u",
|
|
33
|
+
"correctionPrompt": "请从头重答。保留用户原本的因果主张强度,直接说明观察到的关联、可能机制和有证据支持的判断。用户没有主张单一因果时,省略针对排他性因果的反驳。用户确实询问因果识别时,具体解释现有证据的分辨能力与需要控制的因素。不要为了表现审慎凭空增加反方立场,不提及本次校正。"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "无端否定参与与后续行动",
|
|
37
|
+
"pattern": "尚不能|不应继续|不进入|不参与|不再",
|
|
38
|
+
"flags": "u",
|
|
39
|
+
"correctionPrompt": "请从头重答。直接说明当前可行的结论、实际工作范围和下一步行动。删除未经用户要求而自行追加的退出、排除、停止或禁止式声明。确有能力限制、失败条件或停止依据时,具体解释条件、原因和可行替代步骤;用户明确要求停止或排除某项工作时继续尊重该要求。用自然陈述表达,保留重要事实与约束,不提及本次校正。"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "假想推论与无关含义澄清",
|
|
43
|
+
"pattern": "(?:这|此)(?:并)?不(?:意味着|代表)|不等于",
|
|
44
|
+
"flags": "u",
|
|
45
|
+
"correctionPrompt": "请从头重答。围绕用户实际表达的内容作答,删除针对未被提出的含义或推论的澄清。不要从一句正常陈述跳到一个更极端的解释,再反驳这个解释。若区别会实质改变结论,直接说明概念、成立条件和证据;真实错误请指出具体错误与依据。优先用肯定式陈述,不提及本次校正。"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "宽泛否定与禁止措辞兜底",
|
|
49
|
+
"pattern": "不能|不得",
|
|
50
|
+
"flags": "u",
|
|
51
|
+
"correctionPrompt": "请从头重答。直接回应原问题,使用自然、具体的陈述,减少无关的禁止式和防御性表述。不要给用户添加未表达过的观点、动机或更强主张,再去警告或纠正。已有事实错误应直接给出错误与依据;重要限制、安全风险、真实能力边界和用户明确约束继续保留,用具体条件、实际影响和可行建议表达。涉及证据时说明支持范围,涉及替代关系时说明用途与条件,涉及行动时说明下一步。删除不影响结论、证据评价或行动建议的免责声明;不要以近义句重新制造对立。不提及本次校正。"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# English rule notes
|
|
2
|
+
|
|
3
|
+
The default configuration now combines the existing eight Chinese groups with nine English groups. English rules are case-insensitive, accept straight and curly apostrophes in common contractions, and use word boundaries and bounded clause spans where applicable. The final broad-negation rules run after the more specific groups.
|
|
4
|
+
|
|
5
|
+
These patterns are lexical heuristics. They do not establish that a statement is irrelevant, incorrect, or defensive. Ordinary comparisons, quotations, code blocks, capability limits, and useful caveats may trigger them. Correction prompts preserve factual accuracy, important uncertainty, risks, user constraints, and the user's requested reply language.
|
|
6
|
+
|
|
7
|
+
## User reports consulted
|
|
8
|
+
|
|
9
|
+
- [OpenAI Community: Strawman reframing](https://community.openai.com/t/strawman-reframing-chatgpt-sometimes-answers-a-stronger-or-different-claim-than-the-user-actually-made/1399610), September 21, 2026. The author describes replacing qualified claims with stronger ones, with examples including “That doesn’t mean X has no effect” and unsolicited correlation/causation reminders. This informed the hypothetical-implication and exclusive-causality groups. The page was fetched directly.
|
|
10
|
+
- [Reddit discussion snapshot: Anyone else tired of how boring and repetitive it is](https://reddit.sentinel-team.org/posts/1vb0419/snapshots/2026-07-31T20%3A47%3A15.30109Z). The captured post singles out “this/that doesn’t mean,” “one thing I’d caution against,” and repetitive rephrasing. This informed implication and formulaic-caveat rules. This is a third-party snapshot, not a verified live Reddit page.
|
|
11
|
+
- [Reddit discussion snapshot: It’s not just X, it’s Y](https://reddit.sentinel-team.org/posts/1ttjctx/snapshots/2026-06-05T19%3A30%3A44.375215Z). The captured discussion describes repeated negative-positive contrasts and also notes that people use the construction naturally. This informed the contrast group and its false-positive warning. Available through search results from a third-party snapshot.
|
|
12
|
+
|
|
13
|
+
These reports supply examples of user experience, not measured prevalence or evidence about training causes. Other English groups are design translations of the Chinese preset, rather than phrases independently verified in these posts.
|
|
14
|
+
|
|
15
|
+
## Coverage
|
|
16
|
+
|
|
17
|
+
- Contrasts: `not X but Y`, `it's not X, it's Y`, `this isn't X. It's Y`, `you're not X—you're Y`, `rather than`, `instead of`.
|
|
18
|
+
- Repackaging: `repackaging`, `passing off`, `reinventing the wheel`.
|
|
19
|
+
- Evidence: `cannot claim`, `shouldn't assume`, `not claiming`, `without assuming`.
|
|
20
|
+
- Replacement: `not a substitute for`, `cannot replace`.
|
|
21
|
+
- Causality: `the sole cause`, `the only reason`, `correlation doesn't imply causation`.
|
|
22
|
+
- Action exclusions: `cannot yet`, `won't engage`, `no longer`.
|
|
23
|
+
- Implications: `that doesn't necessarily mean`, `this does not prove`, `not equivalent to`.
|
|
24
|
+
- Formulaic caveats: `one important distinction`, `one caveat`, `one thing I'd caution against`, `it's important to note`, `to be clear`.
|
|
25
|
+
- Broad fallback: `cannot`, `can't`, `must not`, `mustn't`.
|
|
26
|
+
|
|
27
|
+
Short prefixes can trigger before a longer, more specific phrase arrives. Rule ordering only resolves matches available in the same accumulated snapshot. The existing retry limit remains two; this change does not alter stream interruption or retry mechanics.
|
package/example.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonhalf383/pi-whoasked",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that detects unwanted phrasing in streamed replies and retries with configurable correction prompts. Includes Chinese and English rules.",
|
|
5
|
+
"repository": { "type": "git", "url": "git+https://github.com/Moonhalf383/pi-whoasked.git" },
|
|
6
|
+
"homepage": "https://github.com/Moonhalf383/pi-whoasked#readme",
|
|
7
|
+
"bugs": { "url": "https://github.com/Moonhalf383/pi-whoasked/issues" },
|
|
8
|
+
"type": "module",
|
|
9
|
+
"keywords": ["pi-package", "pi-extension"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"files": ["src", "configs", "docs", "whoasked.example.json", "whoasked.jpg", "example.png", "README.md", "README.zh-CN.md"],
|
|
13
|
+
"pi": { "extensions": ["./src/index.ts"] },
|
|
14
|
+
"engines": { "node": ">=22.19.0" },
|
|
15
|
+
"peerDependencies": { "@earendil-works/pi-coding-agent": "*" },
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
18
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
19
|
+
"@types/node": "^24.0.0",
|
|
20
|
+
"typescript": "^5.9.3"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test tests/*.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"check": "npm run typecheck && npm test",
|
|
26
|
+
"prepack": "npm run check",
|
|
27
|
+
"pack:check": "node scripts/check-package.mjs"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
4
|
+
export { DEFAULT_CONFIG };
|
|
5
|
+
|
|
6
|
+
export interface Rule {
|
|
7
|
+
name: string;
|
|
8
|
+
pattern: string;
|
|
9
|
+
flags?: string;
|
|
10
|
+
correctionPrompt: string;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Config {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
maxRetries: number;
|
|
17
|
+
rules: Rule[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CompiledRule extends Rule {
|
|
21
|
+
regex: RegExp;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
26
|
+
throw new Error(`${label} must be an object`);
|
|
27
|
+
}
|
|
28
|
+
return value as Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nonempty(value: unknown, label: string): string {
|
|
32
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a nonempty string`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function boolean(value: unknown, label: string): boolean {
|
|
37
|
+
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function compileRules(rules: Rule[]): CompiledRule[] {
|
|
42
|
+
return rules.filter((rule) => rule.enabled !== false).map((rule) => {
|
|
43
|
+
let regex: RegExp;
|
|
44
|
+
try {
|
|
45
|
+
// Global scanning lets the matcher skip empty alternatives and still find a
|
|
46
|
+
// subsequent nonempty hit. Sticky matching, if explicitly requested, is preserved.
|
|
47
|
+
const flags = rule.flags ?? "u";
|
|
48
|
+
regex = new RegExp(rule.pattern, flags.includes("g") ? flags : `${flags}g`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(`Invalid regex for rule "${rule.name}": ${String(error)}`);
|
|
51
|
+
}
|
|
52
|
+
if (regex.test("")) throw new Error(`Rule "${rule.name}" must not match empty input`);
|
|
53
|
+
regex.lastIndex = 0;
|
|
54
|
+
return { ...rule, regex };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Each supplied rules array replaces the previous array, rather than merging by name. */
|
|
59
|
+
export function parseConfig(value: unknown, base: Config = DEFAULT_CONFIG): Config {
|
|
60
|
+
const raw = object(value, "Configuration");
|
|
61
|
+
const unknown = Object.keys(raw).filter((key) => !["enabled", "maxRetries", "rules"].includes(key));
|
|
62
|
+
if (unknown.length) throw new Error(`Unknown configuration fields: ${unknown.join(", ")}`);
|
|
63
|
+
const enabled = raw.enabled === undefined ? base.enabled : boolean(raw.enabled, "enabled");
|
|
64
|
+
const maxRetries = raw.maxRetries === undefined ? base.maxRetries : raw.maxRetries;
|
|
65
|
+
if (typeof maxRetries !== "number" || !Number.isSafeInteger(maxRetries) || maxRetries < 0) {
|
|
66
|
+
throw new Error("maxRetries must be a nonnegative safe integer");
|
|
67
|
+
}
|
|
68
|
+
let rules = base.rules;
|
|
69
|
+
if (raw.rules !== undefined) {
|
|
70
|
+
if (!Array.isArray(raw.rules)) throw new Error("rules must be an array");
|
|
71
|
+
const names = new Set<string>();
|
|
72
|
+
rules = raw.rules.map((value, index) => {
|
|
73
|
+
const rule = object(value, `rules[${index}]`);
|
|
74
|
+
const extra = Object.keys(rule).filter((key) => !["name", "pattern", "flags", "correctionPrompt", "enabled"].includes(key));
|
|
75
|
+
if (extra.length) throw new Error(`rules[${index}] has unknown fields: ${extra.join(", ")}`);
|
|
76
|
+
const name = nonempty(rule.name, `rules[${index}].name`);
|
|
77
|
+
if (names.has(name)) throw new Error(`Duplicate rule name: ${name}`);
|
|
78
|
+
names.add(name);
|
|
79
|
+
const result: Rule = {
|
|
80
|
+
name,
|
|
81
|
+
pattern: nonempty(rule.pattern, `${name}.pattern`),
|
|
82
|
+
correctionPrompt: nonempty(rule.correctionPrompt, `${name}.correctionPrompt`),
|
|
83
|
+
};
|
|
84
|
+
if (rule.flags !== undefined) {
|
|
85
|
+
if (typeof rule.flags !== "string") throw new Error(`${name}.flags must be a string`);
|
|
86
|
+
result.flags = rule.flags;
|
|
87
|
+
}
|
|
88
|
+
if (rule.enabled !== undefined) result.enabled = boolean(rule.enabled, `${name}.enabled`);
|
|
89
|
+
return result;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const config = { enabled, maxRetries, rules };
|
|
93
|
+
// Validate disabled rules too: enabling one later should not reveal a latent syntax error.
|
|
94
|
+
compileRules(rules.map((rule) => ({ ...rule, enabled: true })));
|
|
95
|
+
return config;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LoadedConfig {
|
|
99
|
+
config: Config;
|
|
100
|
+
paths: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Explicit CLI path is exclusive. Otherwise: defaults < personal < trusted project. */
|
|
104
|
+
export async function loadConfig(options: {
|
|
105
|
+
cwd: string;
|
|
106
|
+
agentDir: string;
|
|
107
|
+
trusted: boolean;
|
|
108
|
+
explicitPath?: string;
|
|
109
|
+
}): Promise<LoadedConfig> {
|
|
110
|
+
const candidates = options.explicitPath
|
|
111
|
+
? [resolve(options.cwd, options.explicitPath)]
|
|
112
|
+
: [join(options.agentDir, "whoasked.json"), ...(options.trusted ? [join(options.cwd, ".pi", "whoasked.json")] : [])];
|
|
113
|
+
let config = parseConfig({});
|
|
114
|
+
const paths: string[] = [];
|
|
115
|
+
for (const path of candidates) {
|
|
116
|
+
let text: string;
|
|
117
|
+
try {
|
|
118
|
+
text = await readFile(path, "utf8");
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!options.explicitPath && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
121
|
+
throw new Error(`Unable to read ${path}: ${String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
config = parseConfig(JSON.parse(text), config);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`${path}: ${String(error)}`);
|
|
127
|
+
}
|
|
128
|
+
paths.push(path);
|
|
129
|
+
}
|
|
130
|
+
return { config, paths };
|
|
131
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { Config } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
// Keep the shipped presets as the single source of rule patterns.
|
|
5
|
+
const chinese = JSON.parse(readFileSync(new URL("../configs/zh-CN.strict.json", import.meta.url), "utf8")) as Config;
|
|
6
|
+
const english = JSON.parse(readFileSync(new URL("../configs/en.strict.json", import.meta.url), "utf8")) as Config;
|
|
7
|
+
|
|
8
|
+
// The Chinese preset has the same categories except for the English caveat group.
|
|
9
|
+
// Use English labels/prompts in the bilingual default; prompts preserve the reply language.
|
|
10
|
+
const chineseDefaults = chinese.rules.map((rule, index) => {
|
|
11
|
+
const counterpart = english.rules[index === chinese.rules.length - 1 ? english.rules.length - 1 : index];
|
|
12
|
+
return { ...rule, name: `${counterpart.name} [zh]`, correctionPrompt: counterpart.correctionPrompt };
|
|
13
|
+
});
|
|
14
|
+
const englishDefaults = english.rules.map((rule) => ({ ...rule, name: `${rule.name} [en]` }));
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CONFIG: Config = {
|
|
17
|
+
enabled: true,
|
|
18
|
+
maxRetries: 2,
|
|
19
|
+
rules: [
|
|
20
|
+
...chineseDefaults.slice(0, -1),
|
|
21
|
+
...englishDefaults.slice(0, -1),
|
|
22
|
+
chineseDefaults.at(-1)!,
|
|
23
|
+
englishDefaults.at(-1)!,
|
|
24
|
+
],
|
|
25
|
+
};
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { compileRules, loadConfig } from "./config.ts";
|
|
4
|
+
import type { CompiledRule, Config } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
const CORRECTION_TYPE = "whoasked-correction";
|
|
7
|
+
|
|
8
|
+
interface Hit {
|
|
9
|
+
rule: CompiledRule;
|
|
10
|
+
matched: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Kept separate from the entry point so lifecycle tests need no Pi runtime imports. */
|
|
14
|
+
export function registerWhoasked(pi: ExtensionAPI, agentDir: string): void {
|
|
15
|
+
let config: Config = { enabled: false, maxRetries: 2, rules: [] };
|
|
16
|
+
let rules: CompiledRule[] = [];
|
|
17
|
+
let paths: string[] = [];
|
|
18
|
+
let retries = 0;
|
|
19
|
+
let pending: Hit | undefined;
|
|
20
|
+
let expectedRetryToken: string | undefined;
|
|
21
|
+
let lastText = "";
|
|
22
|
+
let handled = false;
|
|
23
|
+
let superseded = false;
|
|
24
|
+
let active = true;
|
|
25
|
+
|
|
26
|
+
function reset(): void {
|
|
27
|
+
retries = 0;
|
|
28
|
+
pending = undefined;
|
|
29
|
+
expectedRetryToken = undefined;
|
|
30
|
+
lastText = "";
|
|
31
|
+
handled = false;
|
|
32
|
+
superseded = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function status(ctx: ExtensionContext): void {
|
|
36
|
+
ctx.ui.setStatus("whoasked", config.enabled ? `whoasked: on (${rules.length})` : "whoasked: off");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function reload(ctx: ExtensionContext): Promise<boolean> {
|
|
40
|
+
try {
|
|
41
|
+
const flag = pi.getFlag("whoasked-config");
|
|
42
|
+
const loaded = await loadConfig({
|
|
43
|
+
cwd: ctx.cwd,
|
|
44
|
+
agentDir,
|
|
45
|
+
trusted: ctx.isProjectTrusted(),
|
|
46
|
+
explicitPath: typeof flag === "string" ? flag : undefined,
|
|
47
|
+
});
|
|
48
|
+
config = loaded.config;
|
|
49
|
+
rules = compileRules(config.rules);
|
|
50
|
+
paths = loaded.paths;
|
|
51
|
+
reset();
|
|
52
|
+
status(ctx);
|
|
53
|
+
return true;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// Fail visibly and disable detection instead of silently applying different rules.
|
|
56
|
+
config = { ...config, enabled: false };
|
|
57
|
+
reset();
|
|
58
|
+
status(ctx);
|
|
59
|
+
ctx.ui.notify(`whoasked: Configuration load failed; detection disabled. ${String(error)}`, "error");
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function inspect(message: { role: string; content?: unknown }, ctx: ExtensionContext): void {
|
|
65
|
+
if (!active || !config.enabled || handled || superseded || pending || ctx.signal?.aborted || ctx.hasPendingMessages()) return;
|
|
66
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) return;
|
|
67
|
+
// Use the complete current snapshot, not individual deltas: patterns may span chunks.
|
|
68
|
+
// Thinking and tool arguments/results are deliberately excluded.
|
|
69
|
+
const text = message.content
|
|
70
|
+
.filter((block) => block.type === "text")
|
|
71
|
+
.map((block) => block.text as string)
|
|
72
|
+
.join("\n");
|
|
73
|
+
if (text === lastText) return;
|
|
74
|
+
lastText = text;
|
|
75
|
+
for (const rule of rules) {
|
|
76
|
+
// g/y expressions are stateful. Each snapshot is an independent match operation.
|
|
77
|
+
rule.regex.lastIndex = 0;
|
|
78
|
+
let match: RegExpExecArray | null;
|
|
79
|
+
while ((match = rule.regex.exec(text)) && match[0].length === 0) {
|
|
80
|
+
// exec() does not advance on an empty match. Advance one Unicode code point
|
|
81
|
+
// where required so a zero-width alternative cannot hide a later real hit.
|
|
82
|
+
const index = rule.regex.lastIndex;
|
|
83
|
+
const unicode = rule.regex.unicode || rule.regex.flags.includes("v");
|
|
84
|
+
rule.regex.lastIndex = index + (unicode && (text.codePointAt(index) ?? 0) > 0xffff ? 2 : 1);
|
|
85
|
+
}
|
|
86
|
+
rule.regex.lastIndex = 0;
|
|
87
|
+
if (!match) continue;
|
|
88
|
+
handled = true;
|
|
89
|
+
if (retries >= config.maxRetries) {
|
|
90
|
+
pi.appendEntry("whoasked-limit", { rule: rule.name, retries });
|
|
91
|
+
ctx.ui.notify(`whoasked: Matched "${rule.name}"; retry limit (${config.maxRetries}) reached. The current reply will continue.`, "warning");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
pending = { rule, matched: match[0] };
|
|
95
|
+
// Do not await abort/idle from inside a stream handler: that would deadlock.
|
|
96
|
+
ctx.abort();
|
|
97
|
+
ctx.ui.notify(`whoasked: Matched "${rule.name}"; interruption requested. Preparing a new reply.`, "warning");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pi.registerFlag("whoasked-config", {
|
|
103
|
+
description: "Path to a whoasked JSON configuration (relative to cwd or absolute)",
|
|
104
|
+
type: "string",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
108
|
+
active = true;
|
|
109
|
+
await reload(ctx);
|
|
110
|
+
});
|
|
111
|
+
pi.on("session_shutdown", () => {
|
|
112
|
+
active = false;
|
|
113
|
+
reset();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
pi.on("input", () => {
|
|
117
|
+
// New input takes precedence over rewriting an old answer, including queued input.
|
|
118
|
+
reset();
|
|
119
|
+
superseded = true;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("before_agent_start", (event) => {
|
|
123
|
+
const details = event.messageType === "custom" ? event.message.details as { retryToken?: string } | undefined : undefined;
|
|
124
|
+
const ownRetry = expectedRetryToken !== undefined && event.messageType === "custom"
|
|
125
|
+
&& event.message.customType === CORRECTION_TYPE && details?.retryToken === expectedRetryToken;
|
|
126
|
+
if (!ownRetry) reset();
|
|
127
|
+
expectedRetryToken = undefined;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
pi.on("message_start", (event) => {
|
|
131
|
+
if (event.message.role === "user") reset();
|
|
132
|
+
if (event.message.role === "assistant") {
|
|
133
|
+
lastText = "";
|
|
134
|
+
handled = false;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
pi.on("message_update", (event, ctx) => inspect(event.message, ctx));
|
|
138
|
+
// Also catches providers which deliver a final text block without text_delta events.
|
|
139
|
+
pi.on("message_end", (event, ctx) => inspect(event.message, ctx));
|
|
140
|
+
|
|
141
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
142
|
+
const hit = pending;
|
|
143
|
+
pending = undefined;
|
|
144
|
+
// Never restart an ordinary user-cancelled run: only our own recorded hit qualifies.
|
|
145
|
+
// Send only AFTER settlement; abort clears/stops queued in-run continuations.
|
|
146
|
+
if (!active || !hit || !event.aborted || !config.enabled || superseded || ctx.hasPendingMessages()) return;
|
|
147
|
+
retries += 1;
|
|
148
|
+
expectedRetryToken = randomUUID();
|
|
149
|
+
pi.sendMessage({
|
|
150
|
+
customType: CORRECTION_TYPE,
|
|
151
|
+
content: [
|
|
152
|
+
`[whoasked: Previous reply interrupted and superseded; matched "${hit.rule.name}"; correction ${retries}/${config.maxRetries}]`,
|
|
153
|
+
hit.rule.correctionPrompt,
|
|
154
|
+
"Answer the original user request from the beginning in the language requested by the user. Reuse existing tool results and avoid repeating completed operations merely to rewrite the reply.",
|
|
155
|
+
].join("\n\n"),
|
|
156
|
+
display: true,
|
|
157
|
+
details: { retryToken: expectedRetryToken, rule: hit.rule.name, matched: hit.matched, retry: retries },
|
|
158
|
+
}, { triggerTurn: true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.registerCommand("whoasked", {
|
|
162
|
+
description: "Configure response correction: status | on | off | reload | cancel",
|
|
163
|
+
handler: async (args, ctx) => {
|
|
164
|
+
switch (args.trim() || "status") {
|
|
165
|
+
case "status":
|
|
166
|
+
ctx.ui.notify([
|
|
167
|
+
`whoasked: ${config.enabled ? "on" : "off"}; maxRetries=${config.maxRetries}; retries=${retries}`,
|
|
168
|
+
`Configuration: ${paths.length ? paths.join(", ") : "Built-in bilingual defaults"}`,
|
|
169
|
+
`Enabled rules: ${rules.map((rule) => rule.name).join(", ") || "None"}`,
|
|
170
|
+
].join("\n"), "info");
|
|
171
|
+
return;
|
|
172
|
+
case "on":
|
|
173
|
+
// Reload also validates configuration before enabling a previously disabled guard.
|
|
174
|
+
if (await reload(ctx)) {
|
|
175
|
+
config.enabled = true;
|
|
176
|
+
status(ctx);
|
|
177
|
+
ctx.ui.notify("whoasked: Detection enabled for this session.", "info");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
case "off":
|
|
181
|
+
config.enabled = false;
|
|
182
|
+
reset();
|
|
183
|
+
status(ctx);
|
|
184
|
+
ctx.ui.notify("whoasked: Detection disabled and pending correction cancelled for this session.", "info");
|
|
185
|
+
return;
|
|
186
|
+
case "cancel":
|
|
187
|
+
reset();
|
|
188
|
+
superseded = true;
|
|
189
|
+
ctx.ui.notify("whoasked: Further corrections cancelled for this request. Detection resumes on the next user input.", "info");
|
|
190
|
+
return;
|
|
191
|
+
case "reload":
|
|
192
|
+
if (await reload(ctx)) ctx.ui.notify("whoasked: Configuration reloaded.", "info");
|
|
193
|
+
return;
|
|
194
|
+
default:
|
|
195
|
+
ctx.ui.notify("Usage: /whoasked [status|on|off|reload|cancel]", "warning");
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { registerWhoasked } from "./extension.ts";
|
|
4
|
+
|
|
5
|
+
export default function whoasked(pi: ExtensionAPI): void {
|
|
6
|
+
registerWhoasked(pi, getAgentDir());
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"maxRetries": 2,
|
|
4
|
+
"rules": [
|
|
5
|
+
{
|
|
6
|
+
"name": "不能声称",
|
|
7
|
+
"pattern": "不能声称",
|
|
8
|
+
"flags": "u",
|
|
9
|
+
"correctionPrompt": "请重新回答原问题。直接说明事实、证据和必要的局限,删除无端引入的‘不能声称’式防御性声明。保留与问题相关的实质信息,不要提及本次校正。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "无端引入冒充关系",
|
|
13
|
+
"pattern": "不将[^。!?\\n]{1,40}冒充为",
|
|
14
|
+
"flags": "u",
|
|
15
|
+
"correctionPrompt": "请重新回答原问题。删除无端引入的‘冒充’指控或防御性声明,直接陈述有依据的信息和相关局限。不要引入原问题没有涉及的对立,不要提及本次校正。"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "不是A而是B(默认关闭,容易误报)",
|
|
19
|
+
"enabled": false,
|
|
20
|
+
"pattern": "不是[^。!?\\n]{1,60}而是",
|
|
21
|
+
"flags": "u",
|
|
22
|
+
"correctionPrompt": "请重新回答原问题。删除没有上下文依据的‘不是A而是B’对立句,改为直接陈述相关信息。保留必要的概念区分和事实限定,不要提及本次校正。"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
package/whoasked.jpg
ADDED
|
Binary file
|