pi-canary 1.1.2 → 1.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.
- package/README.md +1 -1
- package/extensions/canary.ts +57 -13
- package/package.json +1 -1
- package/skills/canary-help/SKILL.md +3 -4
- package/extensions/canary.json +0 -6
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ If verification fails, a warning notification appears in the TUI.
|
|
|
38
38
|
|
|
39
39
|
## Configuration
|
|
40
40
|
|
|
41
|
-
Persistent configuration lives in `extensions/canary.json
|
|
41
|
+
Persistent configuration lives in `extensions/canary.json` (auto-created on first load with defaults). You can ask the agent to edit it directly:
|
|
42
42
|
|
|
43
43
|
```json
|
|
44
44
|
{
|
package/extensions/canary.ts
CHANGED
|
@@ -15,23 +15,36 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { readFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
|
|
22
|
+
// Config lives next to the extension file: ./extensions/canary.json
|
|
23
|
+
// Auto-created on first load with defaults; travels with the extension.
|
|
24
|
+
const EXT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const CONFIG_PATH = join(EXT_DIR, "canary.json");
|
|
26
|
+
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
COUNT: 3,
|
|
29
|
+
POSITION: "end" as const,
|
|
30
|
+
VARIANT: "fixed" as const,
|
|
31
|
+
FAIL_COMPACT: 0,
|
|
32
|
+
};
|
|
33
|
+
|
|
22
34
|
// Loaded from sibling JSON at startup; /set overrides for the current session only
|
|
23
|
-
const cfg = (() => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const cfg: typeof DEFAULTS & { COUNT: number; FAIL_COMPACT: number } = (() => {
|
|
36
|
+
// Ensure config file exists with defaults
|
|
37
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf-8");
|
|
40
|
+
} catch {
|
|
41
|
+
// If we can't write (e.g. permissions), just use defaults in memory
|
|
42
|
+
}
|
|
43
|
+
}
|
|
30
44
|
try {
|
|
31
|
-
|
|
32
|
-
return { ...defaults, ...JSON.parse(readFileSync(join(extDir, "canary.json"), "utf-8")) };
|
|
45
|
+
return { ...DEFAULTS, ...JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) };
|
|
33
46
|
} catch {
|
|
34
|
-
return
|
|
47
|
+
return { ...DEFAULTS };
|
|
35
48
|
}
|
|
36
49
|
})();
|
|
37
50
|
|
|
@@ -85,6 +98,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
85
98
|
let currentTokens: string[] | null = null;
|
|
86
99
|
// Reused across turns in fixed mode; null forces regeneration
|
|
87
100
|
let fixedTokens: string[] | null = null;
|
|
101
|
+
// Saved before Phase 1 replaces the last user message; restored in Phase 2
|
|
102
|
+
// if the model refused/errored so the Jinja2 template still sees a real user query.
|
|
103
|
+
let originalUserMessage: string | null = null;
|
|
88
104
|
let consecutiveFailures = 0;
|
|
89
105
|
// Guard against double-injection if context fires twice before message_end (retries)
|
|
90
106
|
let verifyContextSent = false;
|
|
@@ -108,6 +124,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
108
124
|
}
|
|
109
125
|
phase = "verifying";
|
|
110
126
|
verifyContextSent = false;
|
|
127
|
+
originalUserMessage = null;
|
|
111
128
|
});
|
|
112
129
|
|
|
113
130
|
pi.on("context", (event, _ctx) => {
|
|
@@ -124,6 +141,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
141
|
// question remains in session history and reappears in Phase 2.
|
|
125
142
|
if (messages.length > 0 && (messages[messages.length - 1] as any).role === "user") {
|
|
126
143
|
const lastMsg = messages[messages.length - 1] as any;
|
|
144
|
+
// Save the original content so Phase 2 can restore it if the model refused/errored
|
|
145
|
+
originalUserMessage =
|
|
146
|
+
typeof lastMsg.content === "string"
|
|
147
|
+
? lastMsg.content
|
|
148
|
+
: Array.isArray(lastMsg.content)
|
|
149
|
+
? lastMsg.content
|
|
150
|
+
.filter((c: any) => c?.type === "text")
|
|
151
|
+
.map((c: any) => c.text)
|
|
152
|
+
.join("\n")
|
|
153
|
+
: null;
|
|
127
154
|
if (typeof lastMsg.content === "string") {
|
|
128
155
|
lastMsg.content = "Please return the canary tokens.";
|
|
129
156
|
} else if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) {
|
|
@@ -167,11 +194,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
167
194
|
|
|
168
195
|
// --- Phase 2: strip the hidden verification exchange ---
|
|
169
196
|
if (phase === "responding") {
|
|
170
|
-
|
|
197
|
+
let messages = event.messages.filter(
|
|
171
198
|
(m: any) => m.customType !== "canary" &&
|
|
172
199
|
m.timestamp !== verifyResponseTimestamp
|
|
173
200
|
);
|
|
174
|
-
if (messages.length !== event.messages.length)
|
|
201
|
+
if (messages.length !== event.messages.length) {
|
|
202
|
+
// If the model refused/errored in Phase 1, the last user message is still
|
|
203
|
+
// the canary prompt ("Please return the canary tokens."). Restore the original
|
|
204
|
+
// so the Jinja2 chat template sees a real user query.
|
|
205
|
+
if (
|
|
206
|
+
originalUserMessage &&
|
|
207
|
+
messages.length > 0 &&
|
|
208
|
+
(messages[messages.length - 1] as any).role === "user"
|
|
209
|
+
) {
|
|
210
|
+
const lastMsg = messages[messages.length - 1] as any;
|
|
211
|
+
const canaryPrompt = "Please return the canary tokens.";
|
|
212
|
+
const lastContent = typeof lastMsg.content === "string" ? lastMsg.content : "";
|
|
213
|
+
if (lastContent === canaryPrompt) {
|
|
214
|
+
lastMsg.content = originalUserMessage;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { messages };
|
|
218
|
+
}
|
|
175
219
|
}
|
|
176
220
|
});
|
|
177
221
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-canary",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Pi extension: silently verifies agent context awareness every turn using hidden canary tokens. KV-cache friendly.",
|
|
5
5
|
"keywords": ["pi-package", "pi", "pi-coding-agent", "extension", "context-awareness", "canary", "safety", "verification", "local-llm"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,10 +36,9 @@ injected into the conversation history. Passed = proceed; failed = warning.
|
|
|
36
36
|
|
|
37
37
|
**Persistent** (survives restarts): edit `canary.json` in the extensions directory.
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
```
|
|
39
|
+
The config lives next to the extension file and is auto-created on first load:
|
|
40
|
+
- **Global install**: `~/.pi/agent/extensions/pi-canary/extensions/canary.json`
|
|
41
|
+
- **Local install**: `<project>/.pi/extensions/pi-canary/extensions/canary.json`
|
|
43
42
|
|
|
44
43
|
Example `canary.json`:
|
|
45
44
|
```json
|