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 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) => {
@@ -116,10 +133,29 @@ export default function (pi: ExtensionAPI) {
116
133
  verifyContextSent = true;
117
134
  const messages = [...event.messages];
118
135
 
119
- // Suppress the original user question so the agent focuses only on the canary check.
120
- // The question remains in session history and reappears in Phase 2.
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.pop();
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
- const messages = event.messages.filter(
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) 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
+ }
166
219
  }
167
220
  });
168
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-canary",
3
- "version": "1.1.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
- 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
- }