@openlines/opl-pi-sht 0.2.0 → 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
|
@@ -71,11 +71,12 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
|
|
|
71
71
|
|
|
72
72
|
| Extension | Summary | Commands, tools, and configuration |
|
|
73
73
|
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
74
|
-
| [`opl-init`](extensions/opl-init/README.md) | Fingerprinted repository-guide generator.
|
|
74
|
+
| [`opl-init`](extensions/opl-init/README.md) | Fingerprinted repository-guide generator with out-of-band model refinement. | `/init`; no config. |
|
|
75
75
|
| [`opl-simplebench`](extensions/opl-simplebench/README.md) | Auditable provider-aware model benchmark with JSON artifacts and metrics. | `/simplebench`, `simplebench`; supports Ollama, OpenAI-compatible providers, and Bedrock; optional `opl-simplebench.json`. |
|
|
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).  | No commands/tools;`opl-input.json`. |
|
|
@@ -107,11 +108,12 @@ 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
|

|
|
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
|
|
113
115
|
|
|
114
|
-
- **`opl-init`** deterministically
|
|
116
|
+
- **`opl-init`** deterministically crawls and refines `AGENTS.md` with a single out-of-band model call (never a synthetic user message), writes it, and reloads context so the session runs on the new guide. A current fingerprint means zero model calls; mid-session invocations wait for the agent to settle instead of interrupting it. Needs pi >= 0.86.0.
|
|
115
117
|

|
|
116
118
|
- **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results — navigation is http(s)-only and screenshots stay in the project directory.
|
|
117
119
|
- **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results, an http(s)-only fetch, a 10 MB response cap, and a 30s timeout.
|
|
@@ -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
|
+
}
|
|
@@ -4,34 +4,23 @@ Generates or refreshes a repository-specific `AGENTS.md` guide through `/init`.
|
|
|
4
4
|
|
|
5
5
|
## Commands, flags, and shortcuts
|
|
6
6
|
|
|
7
|
-
- `/init`:
|
|
8
|
-
- `/init --refine`: same write, then one injected user turn asking the model to expand the guide into a fuller, evidence-based version. Opt-in only: plain `/init` never touches the transcript.
|
|
7
|
+
- `/init`: the only interface; there are no flags and unknown args are ignored. If the guide's fingerprint is current it stops and says so - zero model calls. If it is missing or stale: deterministic crawl, one out-of-band model refinement, extension-validated write, then `ctx.reload()` so the running session picks the new guide up.
|
|
9
8
|
- No shortcuts.
|
|
10
9
|
|
|
11
10
|
## Extension features
|
|
12
11
|
|
|
13
|
-
- Produces a deterministic repository guide with package commands and a file-type inventory.
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
12
|
+
- Produces a deterministic repository guide with package commands and a file-type inventory, then refines it with **one** `ctx.modelRegistry.streamSimple()` call on the current model (pi >= 0.86.0). The refinement never enters the conversation: no synthetic user message, no turn in the transcript, no `write` tool needed, and read-only/chat/plan modes refine identically.
|
|
13
|
+
- The model only sees a bounded evidence packet (the baseline guide, every `package.json`'s full scripts block, and the first ~2 KB of each root/workspace `README`/`CLAUDE.md`, hard 24 KB budget with an explicit truncation line). The crawl is the only explorer.
|
|
14
|
+
- The extension owns the fingerprint marker: model output is stripped of fences and any `opl-init:fp` comment, and the exact marker is appended as the final line. A model can never corrupt or fake a "current" guide.
|
|
15
|
+
- Refine failure (no model, no auth, provider error, empty output) writes the deterministic baseline and notifies `refine failed` - `/init` only fails to write if the write itself fails.
|
|
16
|
+
- Mid-session `/init` waits for the agent to settle (`ctx.waitForIdle()`), recomputes the fingerprint, and only then crawls. It never steers or interrupts the task in flight.
|
|
17
|
+
- Crawls to depth 3, ignores generated/dependency directories, caps directory listings at 40 entries, caps the rendered tree at 300 lines with explicit omission markers, and re-walks declared `pnpm-workspace.yaml`/Cargo members with their own depth-3 budget.
|
|
18
|
+
- Fingerprints dirty tracked + untracked file **contents** (plus HEAD and a generator schema version), excluding the root `AGENTS.md` itself, so writing the guide never makes it immediately stale and re-editing an already-modified file is never read as "current".
|
|
20
19
|
|
|
21
|
-
##
|
|
20
|
+
## Requirements
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
What a `--refine` injection does to a live session:
|
|
26
|
-
|
|
27
|
-
- It is a **user-role turn**. It is persisted in the session and replayed as context on every later turn and on resume, so two `--refine` runs in one session leave two instruction blocks behind and the transcript keeps paying for both.
|
|
28
|
-
- It **always triggers a turn**. With an idle agent the refinement starts immediately. With an agent already streaming it is queued as a follow-up (`deliverAs: "followUp"`), so the guide is rewritten *after* the current turn finishes — later than you asked, and possibly after you have moved on to something else. An injection with no `deliverAs` makes Pi throw `Agent is already processing` and the request is lost.
|
|
29
|
-
- It **cannot be fulfilled in a read-only mode**. Chat and plan modes carry no `write` tool, so the model can only describe the guide it was asked to write: the turn is spent and `AGENTS.md` stays at the deterministic baseline. Run `--refine` in normal (OFF) mode.
|
|
30
|
-
- In **execute mode it ends the plan**. `opl-modes` leaves execute mode when a turn completes without `plan_complete`, so the refinement turn finishing drops the session to OFF mid-plan.
|
|
31
|
-
- A refined guide is **not sticky**. The fingerprint is `HEAD` plus `git status`, so the next commit or dirty file makes the guide stale, and the next plain `/init` overwrites the refined prose with the baseline. Refine last, then commit `AGENTS.md`; if you refine and keep working, expect to refine once more at the end of the session.
|
|
32
|
-
- The refinement's own write does **not** invalidate the marker: `AGENTS.md` is filtered out of the status fingerprint, so a successful `--refine` leaves `/init` reporting "current".
|
|
33
|
-
- There is **no recursion and no automatic re-injection**. The message is sent with `expandPromptTemplates: false`, so a `/init` appearing inside it is not re-dispatched, and the extension registers no event handlers: every injection comes from you typing `--refine`.
|
|
22
|
+
Refinement needs pi 0.86.0 or newer (`ctx.modelRegistry.streamSimple`, `ctx.reload`). Run `pi update` first if `/init` reports a refine failure on a machine with configured auth.
|
|
34
23
|
|
|
35
24
|
## Architecture
|
|
36
25
|
|
|
37
|
-
`index.ts` owns crawling, fingerprinting,
|
|
26
|
+
`index.ts` owns crawling, fingerprinting, the evidence packet, the model call, output validation, and writing. Git repositories fingerprint `schema version + HEAD + sha256 of every dirty tracked and untracked file` (root `AGENTS.md` excluded); non-git repositories use sorted path/size/mtime tuples. `/init` writes the file itself and reloads the session context; the main agent is never enlisted.
|
|
@@ -20,40 +20,68 @@ const MANIFESTS = new Set([
|
|
|
20
20
|
const MAX_DEPTH = 3;
|
|
21
21
|
const MAX_MEMBER_DEPTH = 3;
|
|
22
22
|
const MAX_TREE_LINES = 300;
|
|
23
|
-
const
|
|
23
|
+
const MAX_MANIFEST_PARSE_BYTES = 262144;
|
|
24
24
|
const MAX_DIR_ENTRIES = 40;
|
|
25
|
+
// Bumping this constant is how generator upgrades invalidate every
|
|
26
|
+
// previously-written guide: it is a fingerprint input.
|
|
27
|
+
const GUIDE_SCHEMA_VERSION = 1;
|
|
25
28
|
|
|
26
29
|
type Crawl = {
|
|
27
30
|
tree: string[];
|
|
28
31
|
extCounts: Map<string, number>;
|
|
29
32
|
manifests: { path: string; content: string }[];
|
|
30
33
|
workspaceMembers: string[];
|
|
34
|
+
seen: Set<string>;
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
// Marker the model appends to AGENTS.md so future runs can detect staleness exactly.
|
|
34
38
|
const FP_MARKER = /<!-- opl-init:fp (\S+) -->/;
|
|
35
39
|
|
|
36
40
|
// Exact snapshot of git-tracked + untracked state. Respects .gitignore for free.
|
|
41
|
+
// Content-based: HEAD + per-path content hashes of every dirty tracked file
|
|
42
|
+
// (vs HEAD, so staged changes count) and every untracked file. Only the root
|
|
43
|
+
// AGENTS.md is excluded, so writing the guide does not make it stale;
|
|
44
|
+
// subdirectory AGENTS.md files are repository facts.
|
|
37
45
|
// Returns null for non-git directories (caller falls back to fingerprintFallback).
|
|
38
46
|
function fingerprintGit(root: string): string | null {
|
|
47
|
+
const head = gitOutput(root, ["rev-parse", "HEAD"]);
|
|
48
|
+
if (head === null) return null;
|
|
49
|
+
const hash = createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\0head:${head}\0`);
|
|
50
|
+
// --relative keeps diff paths cwd-relative and cwd-scoped, matching
|
|
51
|
+
// ls-files --others and the crawl; without it a subdirectory session would
|
|
52
|
+
// resolve every dirty path to a nonexistent file and hash "DELETED".
|
|
53
|
+
for (const path of (gitOutput(root, ["diff", "HEAD", "--name-only", "-z", "--relative"]) ?? "").split("\0")) {
|
|
54
|
+
if (!path || path === "AGENTS.md") continue;
|
|
55
|
+
hash.update(`${path}\0`).update(hashFile(join(root, path))).update("\0");
|
|
56
|
+
}
|
|
57
|
+
for (const path of (gitOutput(root, ["ls-files", "--others", "--exclude-standard", "-z"]) ?? "").split("\0")) {
|
|
58
|
+
if (!path || path === "AGENTS.md") continue;
|
|
59
|
+
hash.update(`${path}\0`).update(hashFile(join(root, path))).update("\0");
|
|
60
|
+
}
|
|
61
|
+
return hash.digest("hex").slice(0, 16);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function gitOutput(root: string, args: string[]): string | null {
|
|
39
65
|
try {
|
|
40
|
-
|
|
66
|
+
return execFileSync("git", args, {
|
|
41
67
|
cwd: root,
|
|
42
68
|
encoding: "utf8",
|
|
43
69
|
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
44
71
|
});
|
|
45
|
-
const status = execFileSync(
|
|
46
|
-
"git",
|
|
47
|
-
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
48
|
-
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
49
|
-
);
|
|
50
|
-
const relevantStatus = status.split("\n").filter(line => !line.endsWith("AGENTS.md")).join("\n");
|
|
51
|
-
return createHash("sha256").update(head).update(relevantStatus).digest("hex").slice(0, 16);
|
|
52
72
|
} catch {
|
|
53
73
|
return null;
|
|
54
74
|
}
|
|
55
75
|
}
|
|
56
76
|
|
|
77
|
+
function hashFile(path: string): string {
|
|
78
|
+
try {
|
|
79
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
80
|
+
} catch {
|
|
81
|
+
return "DELETED";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
// ponytail: stat-only (path|size|mtime), not content hashing. Catches add/delete/
|
|
58
86
|
// rename but shares the touch-without-change blind spot; only used for non-git dirs.
|
|
59
87
|
function fingerprintFallback(root: string): string {
|
|
@@ -85,7 +113,7 @@ function fingerprintFallback(root: string): string {
|
|
|
85
113
|
|
|
86
114
|
walk(root);
|
|
87
115
|
parts.sort();
|
|
88
|
-
return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
|
|
116
|
+
return createHash("sha256").update(`schema:${GUIDE_SCHEMA_VERSION}\n`).update(parts.join("\n")).digest("hex").slice(0, 16);
|
|
89
117
|
}
|
|
90
118
|
|
|
91
119
|
function fingerprint(root: string): string {
|
|
@@ -98,6 +126,7 @@ function crawl(root: string): Crawl {
|
|
|
98
126
|
extCounts: new Map(),
|
|
99
127
|
manifests: [],
|
|
100
128
|
workspaceMembers: [],
|
|
129
|
+
seen: new Set(),
|
|
101
130
|
};
|
|
102
131
|
|
|
103
132
|
function walk(dir: string, depth: number, prefix: string, maxDepth: number) {
|
|
@@ -128,17 +157,23 @@ function crawl(root: string): Crawl {
|
|
|
128
157
|
}
|
|
129
158
|
|
|
130
159
|
files.push(entry);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
160
|
+
// Dedupe by absolute path: declared-member re-walks overlap the root
|
|
161
|
+
// walk, and the inventory must not double-count. Tree lines still
|
|
162
|
+
// accept shallow overlap for completeness.
|
|
163
|
+
if (!result.seen.has(path)) {
|
|
164
|
+
result.seen.add(path);
|
|
165
|
+
const extension = extname(entry) || entry;
|
|
166
|
+
result.extCounts.set(extension, (result.extCounts.get(extension) ?? 0) + 1);
|
|
167
|
+
|
|
168
|
+
if (MANIFESTS.has(entry)) {
|
|
169
|
+
try {
|
|
170
|
+
result.manifests.push({
|
|
171
|
+
path: relative(root, path),
|
|
172
|
+
content: readFileSync(path, "utf8").slice(0, MAX_MANIFEST_PARSE_BYTES),
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
// Skip unreadable manifests.
|
|
176
|
+
}
|
|
142
177
|
}
|
|
143
178
|
}
|
|
144
179
|
}
|
|
@@ -279,87 +314,194 @@ function result_tree_paths(root: string): string[] {
|
|
|
279
314
|
return paths;
|
|
280
315
|
}
|
|
281
316
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
317
|
+
// Root-first, per-package-labeled script lines, bounded at 30 entries total.
|
|
318
|
+
function aggregateScripts(manifests: { path: string; content: string }[]): string | null {
|
|
319
|
+
const lines: string[] = [];
|
|
320
|
+
const sorted = [...manifests].sort((a, b) =>
|
|
321
|
+
a.path === "package.json" ? -1 : b.path === "package.json" ? 1 : a.path.localeCompare(b.path));
|
|
322
|
+
for (const manifest of sorted) {
|
|
323
|
+
if (!manifest.path.endsWith("package.json") || lines.length >= 30) continue;
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(manifest.content);
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const scripts = parsed?.scripts;
|
|
331
|
+
if (!scripts || typeof scripts !== "object") continue;
|
|
332
|
+
const label = manifest.path === "package.json" ? "" : `${manifest.path.replace(/\/package\.json$/, "")}/ `;
|
|
333
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
334
|
+
if (lines.length >= 30) break;
|
|
335
|
+
lines.push(` ${label}${name}: ${command}`);
|
|
336
|
+
}
|
|
291
337
|
}
|
|
338
|
+
return lines.length ? lines.join("\n") : null;
|
|
292
339
|
}
|
|
293
340
|
|
|
294
341
|
function buildGuide(root: string, crawlResult: Crawl, marker: string): string {
|
|
295
342
|
const topLevel = crawlResult.tree.filter(line => !line.startsWith(" ")).slice(0, 30).join(", ");
|
|
296
|
-
const scripts = crawlResult.manifests
|
|
297
|
-
.map(manifest => manifest.path.endsWith("package.json") ? packageScripts(manifest.content) : null)
|
|
298
|
-
.find(Boolean);
|
|
343
|
+
const scripts = aggregateScripts(crawlResult.manifests);
|
|
299
344
|
const commandBlock = scripts ? "Package scripts:\n```\n" + scripts + "\n```" : "No package scripts were detected by the repository crawl.";
|
|
300
345
|
return `# Repository Guide\n\n## What this is\n\nRepository at \`${root}\`. Use the repository files as the source of truth; the top-level inventory includes ${topLevel || "(not available)"}.\n\n## Commands\n\n${commandBlock}\n\n## Repository inventory\n\n- File types: ${[...crawlResult.extCounts.entries()].map(([extension, count]) => `${extension} (${count})`).join(", ") || "none detected"}.\n- Inspect specific files before changing behavior; this guide is a starting point, not a substitute for reading the code.\n\n## Agent workflow\n\nKeep changes focused on the requested behavior, preserve existing interfaces, and run the narrowest relevant test before the full suite. Keep secrets and generated output out of tracked configuration.\n${marker}\n`;
|
|
301
346
|
}
|
|
302
347
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
348
|
+
const MAX_EVIDENCE_BYTES = 24576;
|
|
349
|
+
const MAX_README_HEAD_BYTES = 2048;
|
|
350
|
+
|
|
351
|
+
// Evidence the model cannot fetch itself: the deterministic stand-in for the
|
|
352
|
+
// tools the refine call does not have, under a hard byte budget. The spec's
|
|
353
|
+
// three parts: baseline guide, full per-package scripts blocks, readme heads.
|
|
354
|
+
function evidencePacket(
|
|
355
|
+
root: string,
|
|
356
|
+
baseline: string,
|
|
357
|
+
crawlResult: { manifests: { path: string; content: string }[]; workspaceMembers: string[] },
|
|
358
|
+
): string {
|
|
359
|
+
const parts = [baseline];
|
|
360
|
+
let used = Buffer.byteLength(baseline, "utf8");
|
|
361
|
+
const push = (block: string): boolean => {
|
|
362
|
+
const bytes = Buffer.byteLength(block, "utf8") + 1;
|
|
363
|
+
if (used + bytes > MAX_EVIDENCE_BYTES) {
|
|
364
|
+
parts.push("(evidence truncated)");
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
used += bytes;
|
|
368
|
+
parts.push(block);
|
|
369
|
+
return true;
|
|
370
|
+
};
|
|
371
|
+
for (const manifest of crawlResult.manifests) {
|
|
372
|
+
if (!manifest.path.endsWith("package.json")) continue;
|
|
373
|
+
let parsed;
|
|
374
|
+
try {
|
|
375
|
+
parsed = JSON.parse(manifest.content);
|
|
376
|
+
} catch {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const scripts = parsed?.scripts;
|
|
380
|
+
if (!scripts || typeof scripts !== "object") continue;
|
|
381
|
+
const block = `=== scripts: ${manifest.path} ===\n${Object.entries(scripts).map(([name, command]) => `${name}: ${command}`).join("\n")}`;
|
|
382
|
+
if (!push(block)) return parts.join("\n");
|
|
383
|
+
}
|
|
384
|
+
const candidates = ["README.md", "readme.md", "README", "CLAUDE.md"];
|
|
385
|
+
const paths = [...candidates, ...crawlResult.workspaceMembers.flatMap((m) => candidates.map((c) => `${m}/${c}`))];
|
|
386
|
+
for (const rel of paths) {
|
|
387
|
+
let head: string;
|
|
388
|
+
try {
|
|
389
|
+
head = readFileSync(join(root, rel), "utf8").slice(0, MAX_README_HEAD_BYTES);
|
|
390
|
+
} catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (!push(`=== ${rel} ===\n${head}`)) break;
|
|
394
|
+
}
|
|
395
|
+
return parts.join("\n");
|
|
319
396
|
}
|
|
320
397
|
|
|
321
|
-
|
|
322
|
-
|
|
398
|
+
const REFINE_SYSTEM_PROMPT = [
|
|
399
|
+
"You are rewriting a repository guide for an AI coding agent.",
|
|
400
|
+
"Input: a draft guide generated from a deterministic repository crawl, plus bounded file excerpts.",
|
|
401
|
+
"Return ONLY the final Markdown document: no preamble, no code fence around the document, no HTML comments.",
|
|
402
|
+
"Do not invent facts unsupported by the input; keep the factual inventory (commands, file types, workspace members).",
|
|
403
|
+
'Prefer these sections when evidence supports them: "## What this is", "## Commands", "## Architecture", "## Configuration and installation", "## Testing and operational quirks", "## Key files". Omit unsupported sections.',
|
|
404
|
+
"Usually 250-700 words. Avoid generic contribution, Git, or pull-request advice.",
|
|
405
|
+
].join("\n");
|
|
406
|
+
|
|
407
|
+
// The model never owns the marker line: every opl-init:fp comment is stripped
|
|
408
|
+
// and the extension's exact marker is appended as the final line.
|
|
409
|
+
function finalizeRefinedGuide(text: string, marker: string): string {
|
|
410
|
+
let body = text.trim();
|
|
411
|
+
// Strip a wrapper fence only when it opens *and* closes; a guide ending in
|
|
412
|
+
// a legitimate code block keeps its closing fence.
|
|
413
|
+
if (/^```[a-zA-Z]*\s*\n/.test(body)) {
|
|
414
|
+
body = body.replace(/^```[a-zA-Z]*\s*\n/, "").replace(/\n```\s*$/, "");
|
|
415
|
+
}
|
|
416
|
+
body = body.replace(/<!-- opl-init:fp \S+ -->/g, "").trimEnd();
|
|
417
|
+
return `${body}\n${marker}\n`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// One out-of-band completion on the current model. No tools, no session
|
|
421
|
+
// message: any failure returns null and the caller writes the baseline.
|
|
422
|
+
// Bounded so a stalled provider cannot leave /init hanging with no UI signal
|
|
423
|
+
// (the old injected turn was Esc-cancellable; this replaces that escape hatch).
|
|
424
|
+
const REFINE_TIMEOUT_MS = 120000;
|
|
425
|
+
|
|
426
|
+
async function refineGuide(ctx: any, evidence: string, marker: string): Promise<string | null> {
|
|
427
|
+
const model = ctx.model;
|
|
428
|
+
if (!model || typeof ctx.modelRegistry?.streamSimple !== "function") return null;
|
|
429
|
+
ctx.ui?.notify?.("opl-init: refining the guide with the current model...", "info");
|
|
430
|
+
try {
|
|
431
|
+
const stream = ctx.modelRegistry.streamSimple(
|
|
432
|
+
model,
|
|
433
|
+
{ systemPrompt: REFINE_SYSTEM_PROMPT, messages: [{ role: "user", content: evidence, timestamp: Date.now() }], tools: [] },
|
|
434
|
+
{ reasoning: false, signal: AbortSignal.timeout(REFINE_TIMEOUT_MS) },
|
|
435
|
+
);
|
|
436
|
+
const result = await stream.result();
|
|
437
|
+
if (!result || result.stopReason === "error" || result.stopReason === "aborted") return null;
|
|
438
|
+
const text = (result.content ?? [])
|
|
439
|
+
.filter((part: any) => part.type === "text")
|
|
440
|
+
.map((part: any) => part.text)
|
|
441
|
+
.join("\n");
|
|
442
|
+
return text.trim() ? finalizeRefinedGuide(text, marker) : null;
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Named exports for fixture tests (tests/opl-init-*.test.mjs).
|
|
449
|
+
export { crawl, fingerprint, evidencePacket, finalizeRefinedGuide, refineGuide };
|
|
323
450
|
|
|
324
451
|
export default function (pi: ExtensionAPI) {
|
|
325
452
|
pi.registerCommand("init", {
|
|
326
|
-
description: "[opl-init] Crawl
|
|
327
|
-
handler: async (
|
|
328
|
-
const refine = /(?:^|\s)--refine(?:\s|$)/.test(args.trim());
|
|
453
|
+
description: "[opl-init] Crawl + refine AGENTS.md out-of-band; zero model calls when current",
|
|
454
|
+
handler: async (_args, ctx) => {
|
|
329
455
|
const root = ctx.cwd;
|
|
330
456
|
const agentsPath = join(root, "AGENTS.md");
|
|
331
|
-
const
|
|
332
|
-
const exists = existsSync(agentsPath);
|
|
333
|
-
|
|
334
|
-
let storedFp: string | null = null;
|
|
335
|
-
if (exists) {
|
|
457
|
+
const storedFp = () => {
|
|
336
458
|
try {
|
|
337
|
-
|
|
459
|
+
return readFileSync(agentsPath, "utf8").match(FP_MARKER)?.[1] ?? null;
|
|
338
460
|
} catch {
|
|
339
|
-
|
|
461
|
+
return null;
|
|
340
462
|
}
|
|
341
|
-
}
|
|
463
|
+
};
|
|
342
464
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (exists && storedFp === currentFp) {
|
|
465
|
+
let currentFp = fingerprint(root);
|
|
466
|
+
if (existsSync(agentsPath) && storedFp() === currentFp) {
|
|
346
467
|
ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!ctx.isIdle()) {
|
|
472
|
+
ctx.ui.notify("opl-init: waiting for the current agent run to settle before writing AGENTS.md.", "info");
|
|
473
|
+
await ctx.waitForIdle();
|
|
474
|
+
// The settling run may have moved the tree; recompute before crawling.
|
|
475
|
+
currentFp = fingerprint(root);
|
|
476
|
+
if (existsSync(agentsPath) && storedFp() === currentFp) {
|
|
477
|
+
ctx.ui.notify("AGENTS.md is current; /init will not modify it.", "info");
|
|
356
478
|
return;
|
|
357
479
|
}
|
|
358
480
|
}
|
|
359
481
|
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
482
|
+
const marker = `<!-- opl-init:fp ${currentFp} -->`;
|
|
483
|
+
// Regenerating replaces the whole file, including hand-edited prose; the
|
|
484
|
+
// repository's version control is the recovery path.
|
|
485
|
+
const crawlResult = crawl(root);
|
|
486
|
+
const baseline = buildGuide(root, crawlResult, marker);
|
|
487
|
+
const refined = await refineGuide(ctx, evidencePacket(root, baseline, crawlResult), marker);
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
writeFileSync(agentsPath, refined ?? baseline, "utf8");
|
|
491
|
+
} catch (error: any) {
|
|
492
|
+
ctx.ui.notify(`Could not write AGENTS.md: ${error?.message || error}`, "error");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
ctx.ui.notify(
|
|
497
|
+
refined
|
|
498
|
+
? "AGENTS.md written from the repository crawl (model-refined)."
|
|
499
|
+
: "refine failed; wrote deterministic AGENTS.md from the repository crawl.",
|
|
500
|
+
"info",
|
|
501
|
+
);
|
|
502
|
+
// Terminal: pi docs say code after ctx.reload() runs in the old frame.
|
|
503
|
+
await ctx.reload();
|
|
504
|
+
return;
|
|
363
505
|
},
|
|
364
506
|
});
|
|
365
507
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlines/opl-pi-sht",
|
|
3
|
-
"version": "0.2.
|
|
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,10 +40,11 @@
|
|
|
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-
|
|
46
|
+
"test:opl-guardian": "bun test tests/opl-guardian.test.mjs && OPL_EXTENSION=opl-guardian bun test tests/extension-smoke.test.mjs",
|
|
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",
|
|
49
50
|
"test:opl-questionnaire": "OPL_EXTENSION=opl-questionnaire bun test tests/extension-smoke.test.mjs",
|