@openlines/opl-pi-sht 0.2.1 → 0.2.2

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
@@ -76,6 +76,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
76
76
  | [`opl-webaccess`](extensions/opl-webaccess/README.md) | Search plus readable URL/PDF retrieval with session recovery. | `web_search`, `fetch_content`, `get_search_content`; `opl-webaccess.json`. |
77
77
  | [`opl-browser`](extensions/opl-browser/README.md) | Chromium automation via Playwright with structured extraction of rendered pages; single dispatcher tool replacing the chrome-devtools MCP. | `browser` (action-based); `opl-browser.json`. |
78
78
  | [`opl-ctxtrim`](extensions/opl-ctxtrim/README.md) | Trims verbose`ctx_*` tool-schema descriptions on outbound provider requests (~67% smaller schema, ~4,700-6,300 tokens/request). Built specifically for the [context-mode](https://github.com/mksglu/context-mode) extension. | No commands/tools; no config. |
79
+ | [`opl-guardian`](extensions/opl-guardian/README.md) | Drops malformed assistant tool calls before session persistence or replay, with project-local JSONL evidence. | No commands/tools; no config. |
79
80
  | [`opl-todo`](extensions/opl-todo/README.md) | Branch-aware task tool, overlay, and task list. | `todo`, `/todos`; `opl-todo.json`. |
80
81
  | [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
81
82
  | [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable). ![pet](images/ss-input-pet.png) | No commands/tools;`opl-input.json`. |
@@ -107,6 +108,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
107
108
  - **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns **per shell segment**, so `cat f && node -e '...'`, `cat x & rm -rf /tmp/x`, and `echo "$(node -e ...)"` can no longer ride the first command's allowance. Destructive checks still fire inside otherwise-safe commands (anchored to command position, so `du -sh` and `find . -name '*.sh'` stay allowed; `find -delete`, `find -fprint`, `git log --output`, `sort -o`, `npm audit fix`, `git clean`, `sudo`, and quote-obfuscated `r"m"`/`-del"ete"` are blocked; `env`/`printenv` are not safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs.
108
109
  ![custom mode sample](images/ss-mode-custom.png)
109
110
  - `load_tools` activation is bounded by the current mode, so a read-only mode cannot be tricked into enabling a write-capable tool.
111
+ - **`opl-guardian`** stops a malformed provider tool-call record from poisoning the session: it removes calls missing an ID or name before Pi persists or replays them, while preserving dropped records in `err/guardian.jsonl` for an upstream report.
110
112
 
111
113
 
112
114
  ### Move through work faster
@@ -137,7 +139,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
137
139
  | If you want to... | Install |
138
140
  | ---------------------------------- | --------------------------------------------------------------- |
139
141
  | Cut token cost with minimal change | `opl-ctxtrim`, `opl-modes` |
140
- | Run the agent safely on real repos | `opl-modes` (pulls in the `opl-input` + `opl-footer` UI bundle) |
142
+ | Run the agent safely on real repos | `opl-modes` (pulls in the `opl-input` + `opl-footer` UI bundle), `opl-guardian` |
141
143
  | Research and drive the web | `opl-webaccess`, `opl-browser` |
142
144
  | Choose models with data | `opl-simplebench` |
143
145
  | The full, coordinated experience | all ten |
@@ -161,6 +163,7 @@ Installing extensions adds tool schemas (name + description + JSON parameters) t
161
163
  | `opl-init` | command only (no tool) | ~0 |
162
164
  | `opl-input` | UI only | ~0 |
163
165
  | `opl-footer` | UI only | ~0 |
166
+ | `opl-guardian` | none (session-integrity guard) | ~0 |
164
167
  | `opl-ctxtrim` | none (payload transformer) | net negative |
165
168
 
166
169
  Command descriptions add roughly another ~120 tokens collectively, and only if your build surfaces them in the prompt or help block.
@@ -218,4 +221,4 @@ A Pi package (npm or Git) still needs the one-time `npx playwright install chrom
218
221
  npm test
219
222
  ```
220
223
 
221
- Run one extension suite with `npm run test:opl-<name>` for `browser`, `footer`, `init`, `input`, `modes`, `questionnaire`, `todo`, `webaccess`, `simplebench`, or `ctxtrim`. Every helper, functional, and selected-entrypoint smoke check uses Bun's named-test reporter; output includes per-test status, timings, and pass/fail totals. Functional tests cover deterministic helpers where practical; smoke tests bundle entrypoints and parse config. They do not test live TUI behavior, provider credentials, network access, or PDF extraction.
224
+ Run one extension suite with `npm run test:opl-<name>` for `browser`, `footer`, `guardian`, `init`, `input`, `modes`, `questionnaire`, `todo`, `webaccess`, `simplebench`, or `ctxtrim`. Every helper, functional, and selected-entrypoint smoke check uses Bun's named-test reporter; output includes per-test status, timings, and pass/fail totals. Functional tests cover deterministic helpers where practical; smoke tests bundle entrypoints and parse config. They do not test live TUI behavior, provider credentials, network access, or PDF extraction.
@@ -0,0 +1,52 @@
1
+ # opl-guardian
2
+
3
+ Prevents malformed assistant tool calls from entering Pi session history. It runs on every provider and model through Pi's `message_end` hook, removes protocol-invalid calls before Pi persists or replays them, and records each removal as JSON Lines evidence.
4
+
5
+ >
6
+ > This is the first feature that opl-guardian can do for now. If I happen to encounter another error/issue that can be (possibly) fixed via extensions, I might add that up in this extension.
7
+ >
8
+
9
+ ## Commands, tools, and configuration
10
+
11
+ No commands, tools, or configuration. Loading the extension is the entire interface and adds no prompt or tool-schema overhead.
12
+
13
+ ## What it guards
14
+
15
+ A tool call is malformed when its `id` or `name` is blank after trimming whitespace. Such a record cannot be safely replayed through OpenAI-compatible tool-call APIs: a later provider request may reject it and block the session until its history is abandoned.
16
+
17
+ `opl-guardian` does not reject an unfamiliar but non-empty tool name. That is a normal model/tool-resolution error, not an invalid protocol envelope.
18
+
19
+ | Assistant response | Guardian behavior |
20
+ | --- | --- |
21
+ | No malformed calls | Leaves the response unchanged. |
22
+ | Valid and malformed calls | Drops only malformed calls, preserves valid calls and `toolUse`, appends a diagnostic, and lets Pi execute the valid calls normally. |
23
+ | Only malformed calls | Removes every call, returns a text-only `stop` response, and tells the user to send another prompt. |
24
+
25
+ When a UI is available, every incident also produces a warning notification.
26
+
27
+ ## Forensic JSONL
28
+
29
+ Each incident appends one JSON object to:
30
+
31
+ ```text
32
+ <project cwd>/err/guardian.jsonl
33
+ ```
34
+
35
+ A record includes timestamp, session ID, project CWD, provider, model, response ID when available, and the removed `toolCall` blocks. It does not log prompts, assistant text, thinking, or tool results.
36
+
37
+ Tool-call arguments can still contain project paths, commands, or user text. Add `err/` to the project’s version-control ignore policy if it is not already ignored, and treat the file as local diagnostic data.
38
+
39
+ If the log write fails, guardian still removes the malformed calls. The session diagnostic and UI warning report that the forensic file could not be written rather than claiming it was saved.
40
+
41
+ ## Architecture
42
+
43
+ ```text
44
+ guardian.ts Pure malformed-call filter and JSONL incident-record builder.
45
+ index.ts Pi message_end handler, project-local JSONL append, UI notification.
46
+ ```
47
+
48
+ The extension has no runtime dependencies. Run its checks with:
49
+
50
+ ```bash
51
+ npm run test:opl-guardian
52
+ ```
@@ -0,0 +1,70 @@
1
+ import type { AssistantMessage, ToolCall } from "@earendil-works/pi-ai";
2
+
3
+ export interface IncidentContext {
4
+ sessionId: string;
5
+ cwd: string;
6
+ }
7
+
8
+ export interface GuardianIncident {
9
+ timestamp: string;
10
+ kind: "malformed_tool_call";
11
+ sessionId: string;
12
+ cwd: string;
13
+ provider: string;
14
+ model: string;
15
+ responseId?: string;
16
+ action: "dropped";
17
+ removedToolCalls: ToolCall[];
18
+ }
19
+
20
+ export interface GuardResult {
21
+ message: AssistantMessage;
22
+ removedToolCalls: ToolCall[];
23
+ }
24
+
25
+ function hasValue(value: unknown): value is string {
26
+ return typeof value === "string" && value.trim().length > 0;
27
+ }
28
+
29
+ export function isMalformedToolCall(block: unknown): block is ToolCall {
30
+ if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "toolCall") {
31
+ return false;
32
+ }
33
+ const toolCall = block as ToolCall;
34
+ return !hasValue(toolCall.id) || !hasValue(toolCall.name);
35
+ }
36
+
37
+ export function guardAssistantMessage(message: AssistantMessage): GuardResult | undefined {
38
+ const removedToolCalls = message.content.filter(isMalformedToolCall);
39
+ if (removedToolCalls.length === 0) return undefined;
40
+
41
+ const content = message.content.filter((block) => !isMalformedToolCall(block));
42
+ const hasValidToolCall = content.some((block) => block.type === "toolCall");
43
+
44
+ return {
45
+ removedToolCalls,
46
+ message: {
47
+ ...message,
48
+ content,
49
+ stopReason: hasValidToolCall ? "toolUse" : "stop",
50
+ },
51
+ };
52
+ }
53
+
54
+ export function buildIncidentRecord(
55
+ message: AssistantMessage,
56
+ context: IncidentContext,
57
+ removedToolCalls: ToolCall[],
58
+ ): GuardianIncident {
59
+ return {
60
+ timestamp: new Date(message.timestamp).toISOString(),
61
+ kind: "malformed_tool_call",
62
+ sessionId: context.sessionId,
63
+ cwd: context.cwd,
64
+ provider: message.provider,
65
+ model: message.model,
66
+ ...(message.responseId ? { responseId: message.responseId } : {}),
67
+ action: "dropped",
68
+ removedToolCalls,
69
+ };
70
+ }
@@ -0,0 +1,81 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ buildIncidentRecord,
8
+ guardAssistantMessage,
9
+ type GuardianIncident,
10
+ } from "./guardian.js";
11
+
12
+ const LOG_PATH = join("err", "guardian.jsonl");
13
+
14
+ export async function appendIncident(cwd: string, incident: GuardianIncident): Promise<void> {
15
+ const directory = join(cwd, "err");
16
+ const path = join(cwd, LOG_PATH);
17
+ await mkdir(directory, { recursive: true });
18
+
19
+ let existed = true;
20
+ try {
21
+ await stat(path);
22
+ } catch {
23
+ existed = false;
24
+ }
25
+
26
+ await appendFile(path, `${JSON.stringify(incident)}\n`, "utf8");
27
+ if (!existed && process.platform !== "win32") {
28
+ await chmod(path, 0o600).catch(() => {});
29
+ }
30
+ }
31
+
32
+ function diagnostic(removed: number, logError?: unknown): string {
33
+ if (logError) {
34
+ const detail = logError instanceof Error ? logError.message : String(logError);
35
+ return `[opl-guardian] Dropped malformed provider tool call(s), but could not write err/guardian.jsonl: ${detail}.`;
36
+ }
37
+ return `[opl-guardian] Dropped ${removed} malformed provider tool call(s). Details: err/guardian.jsonl.`;
38
+ }
39
+
40
+ function appendDiagnostic(message: AssistantMessage, text: string, invalidOnly: boolean): AssistantMessage {
41
+ const content = invalidOnly
42
+ ? [{ type: "text" as const, text: `${text} No tool was executed; send another prompt to continue.` }]
43
+ : [...message.content, { type: "text" as const, text }];
44
+ return { ...message, content, stopReason: invalidOnly ? "stop" : "toolUse" };
45
+ }
46
+
47
+ export async function guardMessageEnd(
48
+ message: AssistantMessage,
49
+ context: { cwd: string; sessionId: string },
50
+ ): Promise<{ message: AssistantMessage; diagnostic: string } | undefined> {
51
+ const guarded = guardAssistantMessage(message);
52
+ if (!guarded) return undefined;
53
+
54
+ let logError: unknown;
55
+ try {
56
+ await appendIncident(context.cwd, buildIncidentRecord(message, context, guarded.removedToolCalls));
57
+ } catch (error) {
58
+ logError = error;
59
+ }
60
+
61
+ const invalidOnly = !guarded.message.content.some((block) => block.type === "toolCall");
62
+ const notice = diagnostic(guarded.removedToolCalls.length, logError);
63
+ return {
64
+ message: appendDiagnostic(guarded.message, notice, invalidOnly),
65
+ diagnostic: notice,
66
+ };
67
+ }
68
+
69
+ export default function (pi: ExtensionAPI) {
70
+ pi.on("message_end", async (event, ctx) => {
71
+ if (event.message.role !== "assistant") return;
72
+ const guarded = await guardMessageEnd(event.message, {
73
+ cwd: ctx.cwd,
74
+ sessionId: ctx.sessionManager.getSessionId(),
75
+ });
76
+ if (!guarded) return;
77
+
78
+ ctx.ui?.notify(guarded.diagnostic, "warning");
79
+ return { message: guarded.message };
80
+ });
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,9 +40,10 @@
40
40
  },
41
41
  "type": "module",
42
42
  "scripts": {
43
- "test": "npm run test:opl-browser && npm run test:opl-footer && npm run test:opl-init && npm run test:opl-input && npm run test:opl-modes && npm run test:opl-questionnaire && npm run test:opl-todo && npm run test:opl-webaccess && npm run test:opl-simplebench && npm run test:opl-ctxtrim",
43
+ "test": "npm run test:opl-browser && npm run test:opl-footer && npm run test:opl-guardian && npm run test:opl-init && npm run test:opl-input && npm run test:opl-modes && npm run test:opl-questionnaire && npm run test:opl-todo && npm run test:opl-webaccess && npm run test:opl-simplebench && npm run test:opl-ctxtrim",
44
44
  "test:opl-browser": "bun test tests/opl-browser-helpers.test.mjs && OPL_EXTENSION=opl-browser bun test tests/extension-smoke.test.mjs",
45
45
  "test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
46
+ "test:opl-guardian": "bun test tests/opl-guardian.test.mjs && OPL_EXTENSION=opl-guardian bun test tests/extension-smoke.test.mjs",
46
47
  "test:opl-init": "bun test tests/opl-init-crawl.test.mjs tests/opl-init-fingerprint.test.mjs tests/opl-init-packet.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
47
48
  "test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
48
49
  "test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs tests/opl-modes-lifecycle.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",