pi-canary 1.1.1 → 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 +69 -16
- 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) => {
|
|
@@ -116,10 +133,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
116
133
|
verifyContextSent = true;
|
|
117
134
|
const messages = [...event.messages];
|
|
118
135
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
136
|
+
// Replace the original user question with a neutral prompt so the agent focuses
|
|
137
|
+
// only on the canary check. We keep the user role (replacing content, not the
|
|
138
|
+
// message) because some providers (e.g. llama-server) use Jinja2 chat templates
|
|
139
|
+
// that require a user message at the end of the conversation — removing it causes
|
|
140
|
+
// template parsing to fail with "No user query found in messages." The original
|
|
141
|
+
// question remains in session history and reappears in Phase 2.
|
|
121
142
|
if (messages.length > 0 && (messages[messages.length - 1] as any).role === "user") {
|
|
122
|
-
messages.
|
|
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;
|
|
154
|
+
if (typeof lastMsg.content === "string") {
|
|
155
|
+
lastMsg.content = "Please return the canary tokens.";
|
|
156
|
+
} else if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) {
|
|
157
|
+
lastMsg.content = [{ type: "text", text: "Please return the canary tokens." }];
|
|
158
|
+
}
|
|
123
159
|
}
|
|
124
160
|
|
|
125
161
|
const histLen = messages.length;
|
|
@@ -158,11 +194,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
158
194
|
|
|
159
195
|
// --- Phase 2: strip the hidden verification exchange ---
|
|
160
196
|
if (phase === "responding") {
|
|
161
|
-
|
|
197
|
+
let messages = event.messages.filter(
|
|
162
198
|
(m: any) => m.customType !== "canary" &&
|
|
163
199
|
m.timestamp !== verifyResponseTimestamp
|
|
164
200
|
);
|
|
165
|
-
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
|
+
}
|
|
166
219
|
}
|
|
167
220
|
});
|
|
168
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
|