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 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`. You can ask the agent to edit it directly:
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
  {
@@ -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
- const defaults = {
25
- COUNT: 3,
26
- POSITION: "end" as "start" | "equidistant" | "end",
27
- VARIANT: "fixed" as "fixed" | "variant",
28
- FAIL_COMPACT: 0,
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
- const extDir = dirname(fileURLToPath(import.meta.url));
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 defaults;
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
- const messages = event.messages.filter(
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) return { messages };
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.1.2",
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
- Global git install path:
40
- ```
41
- ~/.pi/agent/git/github.com/sebaxzero/pi-canary/extensions/canary.json
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
@@ -1,6 +0,0 @@
1
- {
2
- "COUNT": 3,
3
- "POSITION": "end",
4
- "VARIANT": "fixed",
5
- "FAIL_COMPACT": 0
6
- }