@openlines/opl-pi-sht 0.2.11 → 0.3.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 +92 -82
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +148 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +32 -7
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +157 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +287 -109
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +144 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +16 -4
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +48 -17
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- package/images/ss-init.png +0 -0
- package/images/ss-input-footer.png +0 -0
- package/images/ss-input-pet.png +0 -0
- package/images/ss-mode-custom.png +0 -0
- package/images/ss-questionnaire0.png +0 -0
- package/images/ss-questionnaire1.png +0 -0
- package/images/ss-simplebench.png +0 -0
- package/images/ss-todo.png +0 -0
- package/package.json +20 -12
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
PLAN_DIR,
|
|
42
42
|
buildExecutePrompt,
|
|
43
43
|
buildRefinePrompt,
|
|
44
|
+
capPlan,
|
|
44
45
|
USER_CONFIG,
|
|
45
46
|
MODE_REGISTRY,
|
|
46
47
|
getModeDefinition,
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
extractPlanText,
|
|
58
59
|
isPlanLike,
|
|
59
60
|
ensurePlanDir,
|
|
61
|
+
planFilePath,
|
|
60
62
|
titleFromFilename,
|
|
61
63
|
listPlanFiles,
|
|
62
64
|
sanitizePlanName,
|
|
@@ -78,6 +80,10 @@ import {
|
|
|
78
80
|
resetState,
|
|
79
81
|
getRefineCount,
|
|
80
82
|
incrementRefineCount,
|
|
83
|
+
publishModeGlobals,
|
|
84
|
+
clearModeGlobals,
|
|
85
|
+
getSessionCwd,
|
|
86
|
+
setSessionCwd,
|
|
81
87
|
} from "./state.js";
|
|
82
88
|
import { showSelectMenu } from "./menus.js";
|
|
83
89
|
|
|
@@ -260,7 +266,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
260
266
|
function getPlanFilePath(): string | null {
|
|
261
267
|
const file = getActivePlanFile();
|
|
262
268
|
if (!file) return null;
|
|
263
|
-
return
|
|
269
|
+
return planFilePath(getSessionCwd(), file);
|
|
264
270
|
}
|
|
265
271
|
|
|
266
272
|
function getPlanDisplayTitle(): string | null {
|
|
@@ -269,7 +275,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
269
275
|
const filePath = getPlanFilePath();
|
|
270
276
|
if (filePath && existsSync(filePath)) {
|
|
271
277
|
const heading = readFileSync(filePath, "utf-8").match(/^# Plan:\s*(.+)$/m);
|
|
272
|
-
|
|
278
|
+
const captured = heading?.[1];
|
|
279
|
+
if (captured) return captured.trim();
|
|
273
280
|
}
|
|
274
281
|
return titleFromFilename(file);
|
|
275
282
|
}
|
|
@@ -444,7 +451,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
444
451
|
/** Open the mode picker. Handles all mode transitions including cross-mode switches. */
|
|
445
452
|
async function openModePicker(ctx: ExtensionContext): Promise<void> {
|
|
446
453
|
const current = getMode();
|
|
447
|
-
const planFiles = listPlanFiles();
|
|
454
|
+
const planFiles = listPlanFiles(getSessionCwd());
|
|
448
455
|
|
|
449
456
|
// Build mode list dynamically from registry (skip invisible/disabled modes)
|
|
450
457
|
const registryModes = Array.from(MODE_REGISTRY.entries())
|
|
@@ -551,6 +558,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
551
558
|
// ─── Event: session_start ──────────────────────────────────────────────────────
|
|
552
559
|
|
|
553
560
|
pi.on("session_start", async (event, ctx) => {
|
|
561
|
+
// Re-publish the footer seam: a previous session in this process cleared it on shutdown.
|
|
562
|
+
publishModeGlobals();
|
|
554
563
|
if (event.reason === "startup") {
|
|
555
564
|
if (pi.getFlag("chat") === true && getMode() === "off") {
|
|
556
565
|
enterChatMode(ctx);
|
|
@@ -565,6 +574,12 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
565
574
|
await syncStateFromBranch(ctx, event.reason === "resume" || event.reason === "fork");
|
|
566
575
|
});
|
|
567
576
|
|
|
577
|
+
pi.on("session_shutdown", async () => {
|
|
578
|
+
// Hand the seam back: an orphaned mode global would keep rendering a mode that no
|
|
579
|
+
// longer belongs to any live session.
|
|
580
|
+
clearModeGlobals();
|
|
581
|
+
});
|
|
582
|
+
|
|
568
583
|
// ─── Event: before_agent_start ──────────────────────────────────────────
|
|
569
584
|
|
|
570
585
|
pi.on("before_agent_start", (event: BeforeAgentStartEvent): BeforeAgentStartEventResult => {
|
|
@@ -578,7 +593,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
578
593
|
const filePath = getPlanFilePath();
|
|
579
594
|
if (filePath && existsSync(filePath)) {
|
|
580
595
|
const planContent = readFileSync(filePath, "utf-8");
|
|
581
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent) };
|
|
596
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent, filePath) };
|
|
582
597
|
}
|
|
583
598
|
}
|
|
584
599
|
|
|
@@ -590,12 +605,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
590
605
|
const customTemplate = modeDef.prompt;
|
|
591
606
|
if (typeof customTemplate === "string" && customTemplate.trim().length > 0) {
|
|
592
607
|
// User-configured template via modes.execute.prompt — {plan} is replaced with the plan file content.
|
|
608
|
+
// Capped here too: a custom template must not become a way around the injection budget.
|
|
609
|
+
const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, filePath).text;
|
|
593
610
|
const filled = customTemplate.includes("{plan}")
|
|
594
|
-
? customTemplate.replace("{plan}",
|
|
595
|
-
: `${customTemplate}\n\nPlan:\n${
|
|
611
|
+
? customTemplate.replace("{plan}", capped)
|
|
612
|
+
: `${customTemplate}\n\nPlan:\n${capped}`;
|
|
596
613
|
return { systemPrompt: event.systemPrompt + "\n\n" + filled };
|
|
597
614
|
}
|
|
598
|
-
return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent) };
|
|
615
|
+
return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent, filePath) };
|
|
599
616
|
}
|
|
600
617
|
|
|
601
618
|
// Generic mode prompt from registry
|
|
@@ -628,7 +645,13 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
628
645
|
if (event.toolName !== "bash") return {};
|
|
629
646
|
if (!modeDef?.safePatterns && !modeDef?.destructivePatterns) return {};
|
|
630
647
|
|
|
631
|
-
|
|
648
|
+
if (typeof event.input.command !== "string") {
|
|
649
|
+
// Uninspectable commands are refused rather than coerced: String(["rm","-rf"]) would
|
|
650
|
+
// reach the pattern matchers as "rm,-rf" and slip past a segment-based safe list.
|
|
651
|
+
return { block: true, reason: `[mode-switcher] Blocked a bash tool call whose command was not a string [${mode} mode].` };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const command = event.input.command;
|
|
632
655
|
const reason = bashBlockReason(command, modeDef.safePatterns, modeDef.destructivePatterns);
|
|
633
656
|
if (reason) {
|
|
634
657
|
return { block: true, reason: `[mode-switcher] Command blocked — ${reason} [${mode} mode]` };
|
|
@@ -699,7 +722,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
699
722
|
setRefining(false);
|
|
700
723
|
|
|
701
724
|
if (planText) {
|
|
702
|
-
const planDir = ensurePlanDir();
|
|
725
|
+
const planDir = ensurePlanDir(getSessionCwd());
|
|
703
726
|
const activeFile = getActivePlanFile();
|
|
704
727
|
let filename = activeFile;
|
|
705
728
|
if (!filename) {
|
|
@@ -850,17 +873,20 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
850
873
|
ctx.ui.notify(USER_CONFIG.labels.plan.notifyLoaded.replace("{title}", displayName), "info");
|
|
851
874
|
}
|
|
852
875
|
|
|
853
|
-
const filePath =
|
|
876
|
+
const filePath = planFilePath(getSessionCwd(), filename);
|
|
854
877
|
if (existsSync(filePath)) {
|
|
855
878
|
const planContent = readFileSync(filePath, "utf-8");
|
|
856
|
-
|
|
879
|
+
// The entry copy is display/session only (plain custom entries never reach the
|
|
880
|
+
// model), but it is written to the session file forever, so it gets its own budget.
|
|
881
|
+
const entry = capPlan(planContent, USER_CONFIG.plan.maxEntryBytes, filePath);
|
|
882
|
+
pi.appendEntry("plan-mode", { title: displayName, plan: entry.text, truncated: entry.truncated, file: filePath });
|
|
857
883
|
}
|
|
858
884
|
}
|
|
859
885
|
|
|
860
886
|
// ─── Entry renderer for TUI-only plan cards (never sent to the model) ──────
|
|
861
887
|
|
|
862
888
|
pi.registerEntryRenderer("plan-mode", (entry, _options, theme) => {
|
|
863
|
-
const data = (entry.data ?? {}) as { title?: string; plan?: string };
|
|
889
|
+
const data = (entry.data ?? {}) as { title?: string; plan?: string; truncated?: boolean; file?: string };
|
|
864
890
|
const border = new DynamicBorder((s: string) => theme.fg("border", s));
|
|
865
891
|
const container = new Container();
|
|
866
892
|
container.addChild(border);
|
|
@@ -870,6 +896,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
870
896
|
container.addChild(new Spacer());
|
|
871
897
|
container.addChild(new Text(body));
|
|
872
898
|
}
|
|
899
|
+
if (data.truncated) {
|
|
900
|
+
container.addChild(new Text(theme.fg("muted", `✂ truncated copy — full plan: ${data.file ?? PLAN_DIR}`)));
|
|
901
|
+
}
|
|
873
902
|
container.addChild(border);
|
|
874
903
|
return container;
|
|
875
904
|
});
|
|
@@ -982,7 +1011,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
982
1011
|
if (!input) {
|
|
983
1012
|
const current = getMode();
|
|
984
1013
|
if (current === "off") {
|
|
985
|
-
const files = listPlanFiles();
|
|
1014
|
+
const files = listPlanFiles(getSessionCwd());
|
|
986
1015
|
if (files.length === 0) {
|
|
987
1016
|
await promptNameAndEnterPlanMode(ctx);
|
|
988
1017
|
} else {
|
|
@@ -1025,7 +1054,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1025
1054
|
return;
|
|
1026
1055
|
}
|
|
1027
1056
|
const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
|
|
1028
|
-
const filePath =
|
|
1057
|
+
const filePath = planFilePath(getSessionCwd(), filename);
|
|
1029
1058
|
|
|
1030
1059
|
if (existsSync(filePath)) {
|
|
1031
1060
|
if (getMode() !== "off") enterOffMode(ctx, undefined, true);
|
|
@@ -1078,13 +1107,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1078
1107
|
pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
|
|
1079
1108
|
return;
|
|
1080
1109
|
}
|
|
1081
|
-
const files = listPlanFiles();
|
|
1110
|
+
const files = listPlanFiles(getSessionCwd());
|
|
1082
1111
|
if (files.length === 0) {
|
|
1083
1112
|
if (ctx.hasUI) ctx.ui.notify("No plan files found. Run /plan first.", "warning");
|
|
1084
1113
|
return;
|
|
1085
1114
|
}
|
|
1086
1115
|
if (files.length === 1) {
|
|
1087
|
-
|
|
1116
|
+
const only = files[0];
|
|
1117
|
+
if (only) await startExecute(only.name);
|
|
1088
1118
|
return;
|
|
1089
1119
|
}
|
|
1090
1120
|
const items: SelectItem[] = files.map(f => ({ value: f.name, label: f.title }));
|
|
@@ -1100,7 +1130,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1100
1130
|
return;
|
|
1101
1131
|
}
|
|
1102
1132
|
const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
|
|
1103
|
-
const filePath =
|
|
1133
|
+
const filePath = planFilePath(getSessionCwd(), filename);
|
|
1104
1134
|
if (!existsSync(filePath)) {
|
|
1105
1135
|
if (ctx.hasUI) ctx.ui.notify(`Plan "${input}" not found. Check /plan for available plans.`, "warning");
|
|
1106
1136
|
return;
|
|
@@ -1124,6 +1154,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
1124
1154
|
|
|
1125
1155
|
const idx = order.indexOf(current);
|
|
1126
1156
|
const next = idx === -1 ? order[0] : order[(idx + 1) % order.length];
|
|
1157
|
+
if (!next) return; // order is non-empty above; this is what lets the branches below take a string
|
|
1127
1158
|
if (next === current) return;
|
|
1128
1159
|
|
|
1129
1160
|
if (next === "off") {
|
|
@@ -7,12 +7,17 @@ import type { AgentMode, AgentModeBlob, ModeAppearanceConfig, ModeModelConfig }
|
|
|
7
7
|
const state: {
|
|
8
8
|
mode: AgentMode;
|
|
9
9
|
activePlanFile: string | null;
|
|
10
|
+
sessionCwd: string;
|
|
10
11
|
restoringModel: ModeModelConfig | null;
|
|
11
12
|
refining: boolean;
|
|
12
13
|
refineCount: number;
|
|
13
14
|
} = {
|
|
14
15
|
mode: "off",
|
|
15
16
|
activePlanFile: null,
|
|
17
|
+
// Until the first `session_start` hands us the real one, the directory the harness was started
|
|
18
|
+
// in is the only candidate. Plans and the footer path must never be resolved from
|
|
19
|
+
// `process.cwd()` at the call site: a session opened elsewhere would write its plans there.
|
|
20
|
+
sessionCwd: process.cwd(),
|
|
16
21
|
restoringModel: null,
|
|
17
22
|
refining: false,
|
|
18
23
|
refineCount: 0,
|
|
@@ -23,6 +28,23 @@ const state: {
|
|
|
23
28
|
(globalThis as Record<string, unknown>).__planMode = { mode: "off" };
|
|
24
29
|
(globalThis as Record<string, unknown>).__chatMode = { mode: "off" };
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Cross-extension seam. `opl-footer` publishes `__footerRequestRender` while a footer is
|
|
33
|
+
* mounted; `__agentMode`, `__planMode` and `__chatMode` are read by the footer's mode
|
|
34
|
+
* segment and by `opl-input`. Extensions install one directory at a time and load in any
|
|
35
|
+
* combination, so nothing here may assume the other side exists or is healthy: the value can
|
|
36
|
+
* be missing, be something else entirely, or close over a TUI that was already torn down.
|
|
37
|
+
*/
|
|
38
|
+
function requestFooterRender(): void {
|
|
39
|
+
const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
|
|
40
|
+
if (typeof requestRender !== "function") return;
|
|
41
|
+
try {
|
|
42
|
+
(requestRender as () => void)();
|
|
43
|
+
} catch {
|
|
44
|
+
// A dead footer must not fail a mode transition.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/** Derive and sync all globalThis snapshots from unified state. */
|
|
27
49
|
function syncGlobalThis(): void {
|
|
28
50
|
const m = state.mode;
|
|
@@ -35,8 +57,23 @@ function syncGlobalThis(): void {
|
|
|
35
57
|
// plan-family modes light up __planMode, so a custom mode cannot read as "Plan mode ON".
|
|
36
58
|
(globalThis as Record<string, unknown>).__planMode = { mode: m === "plan" || m === "execute" ? m : "off" };
|
|
37
59
|
(globalThis as Record<string, unknown>).__chatMode = { mode: m === "chat" ? "chat" : "off" };
|
|
38
|
-
|
|
39
|
-
|
|
60
|
+
requestFooterRender();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Re-publish the seam after a fresh session starts in this process. */
|
|
64
|
+
export function publishModeGlobals(): void {
|
|
65
|
+
syncGlobalThis();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drop the seam when the session ends. A leftover value would let a footer in a later
|
|
70
|
+
* session render a mode that belongs to nobody, and the footer's own render trigger is
|
|
71
|
+
* removed with it (see `opl-footer`).
|
|
72
|
+
*/
|
|
73
|
+
export function clearModeGlobals(): void {
|
|
74
|
+
delete (globalThis as Record<string, unknown>).__agentMode;
|
|
75
|
+
delete (globalThis as Record<string, unknown>).__planMode;
|
|
76
|
+
delete (globalThis as Record<string, unknown>).__chatMode;
|
|
40
77
|
}
|
|
41
78
|
|
|
42
79
|
export function getMode(): AgentMode { return state.mode; }
|
|
@@ -126,6 +163,7 @@ export function restore(
|
|
|
126
163
|
): boolean {
|
|
127
164
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
128
165
|
const entry = entries[i];
|
|
166
|
+
if (!entry) continue; // a hole in the persisted array is not a restore candidate
|
|
129
167
|
if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
|
|
130
168
|
|
|
131
169
|
const data = entry.data as AgentModeBlob | undefined;
|
|
@@ -145,6 +183,7 @@ export function restore(
|
|
|
145
183
|
// Backward compat: restore from legacy entries written by the old extensions.
|
|
146
184
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
147
185
|
const entry = entries[i];
|
|
186
|
+
if (!entry) continue; // a hole in the persisted array is not a restore candidate
|
|
148
187
|
if (entry.type !== "custom") continue;
|
|
149
188
|
|
|
150
189
|
if (entry.customType === "plan-mode") {
|
|
@@ -181,3 +220,12 @@ export function resetState(): void {
|
|
|
181
220
|
state.refineCount = 0;
|
|
182
221
|
syncGlobalThis();
|
|
183
222
|
}
|
|
223
|
+
|
|
224
|
+
/** The directory the current session lives in (`.pi/plans/` is resolved against this). */
|
|
225
|
+
export function getSessionCwd(): string {
|
|
226
|
+
return state.sessionCwd;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function setSessionCwd(cwd: string): void {
|
|
230
|
+
if (cwd) state.sessionCwd = cwd;
|
|
231
|
+
}
|
|
@@ -154,6 +154,12 @@ export interface ModeSwitcherUserConfig {
|
|
|
154
154
|
shortcuts?: ShortcutsUserConfig;
|
|
155
155
|
/** @deprecated Use `modes` registry instead. Kept for backward compat. */
|
|
156
156
|
labels?: LabelsUserConfig;
|
|
157
|
+
plan?: {
|
|
158
|
+
/** Ceiling for a plan body injected into a system prompt, in bytes (default 24000). Larger plans truncate with a marker pointing at the file. */
|
|
159
|
+
maxInjectBytes?: number;
|
|
160
|
+
/** Ceiling for the plan copy kept in the `plan-mode` session entry, in bytes (default 4096). */
|
|
161
|
+
maxEntryBytes?: number;
|
|
162
|
+
};
|
|
157
163
|
bashPatterns?: BashPatternsUserConfig;
|
|
158
164
|
/** Replace-only: provide a list of tool names to replace the default CHAT mode tool set. Omit to keep defaults. */
|
|
159
165
|
chatAllowedTools?: string[];
|
|
@@ -52,6 +52,7 @@ function commandSegments(command: string): string[] {
|
|
|
52
52
|
for (let i = 0; i < command.length; i++) {
|
|
53
53
|
const ch = command[i];
|
|
54
54
|
const top = stack[stack.length - 1];
|
|
55
|
+
if (!top) break; // stack drained: the rest of the command is literal text
|
|
55
56
|
// Single quotes are literal: nothing expands, only the closing ' matters.
|
|
56
57
|
if (top.quote === "'") {
|
|
57
58
|
if (ch === "'") top.quote = "";
|
|
@@ -165,9 +166,9 @@ export function extractPlanText(message: string): string | null {
|
|
|
165
166
|
|
|
166
167
|
// ─── Plan File I/O ────────────────────────────────────────────────────────────
|
|
167
168
|
|
|
168
|
-
/** Ensure
|
|
169
|
-
export function ensurePlanDir(): string {
|
|
170
|
-
const dir = join(
|
|
169
|
+
/** Ensure <sessionCwd>/.pi/plans/ exists, return its absolute path. */
|
|
170
|
+
export function ensurePlanDir(cwd: string): string {
|
|
171
|
+
const dir = join(cwd, PLAN_DIR);
|
|
171
172
|
mkdirSync(dir, { recursive: true });
|
|
172
173
|
return dir;
|
|
173
174
|
}
|
|
@@ -181,7 +182,12 @@ export function titleFromFilename(filename: string): string {
|
|
|
181
182
|
// Detect timestamp pattern: YYYY-MM-DD-HH-MM
|
|
182
183
|
const tsMatch = stem.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})$/);
|
|
183
184
|
if (tsMatch) {
|
|
184
|
-
|
|
185
|
+
// Five digit groups by the shape test above; the explicit checks are what let the compiler see
|
|
186
|
+
// that, rather than a cast.
|
|
187
|
+
const [y, m, d, h, min] = tsMatch.slice(1);
|
|
188
|
+
if (y === undefined || m === undefined || d === undefined || h === undefined || min === undefined) {
|
|
189
|
+
return stem.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
190
|
+
}
|
|
185
191
|
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
186
192
|
const month = months[parseInt(m, 10) - 1] ?? m;
|
|
187
193
|
return `${month} ${parseInt(d, 10)}, ${y} ${h}:${min}`;
|
|
@@ -203,8 +209,8 @@ export function sanitizePlanName(name: string): string | null {
|
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
/** List available plan files in .pi/plans/ with titles from # Plan: heading. */
|
|
206
|
-
export function listPlanFiles(): PlanFileSummary[] {
|
|
207
|
-
const dir = join(
|
|
212
|
+
export function listPlanFiles(cwd: string): PlanFileSummary[] {
|
|
213
|
+
const dir = join(cwd, PLAN_DIR);
|
|
208
214
|
if (!existsSync(dir)) return [];
|
|
209
215
|
|
|
210
216
|
const files = readdirSync(dir)
|
|
@@ -214,7 +220,7 @@ export function listPlanFiles(): PlanFileSummary[] {
|
|
|
214
220
|
return files.map((filename) => {
|
|
215
221
|
const content = readFileSync(join(dir, filename), "utf-8");
|
|
216
222
|
const titleMatch = content.match(/^# Plan:\s*(.+)$/m);
|
|
217
|
-
const title = titleMatch ? titleMatch[1].trim() : titleFromFilename(filename);
|
|
223
|
+
const title = titleMatch?.[1] ? titleMatch[1].trim() : titleFromFilename(filename);
|
|
218
224
|
return { name: filename, title };
|
|
219
225
|
});
|
|
220
226
|
}
|
|
@@ -265,4 +271,8 @@ export function applyLabelColor(theme: Theme, color: string, text: string): stri
|
|
|
265
271
|
} catch {
|
|
266
272
|
return text;
|
|
267
273
|
}
|
|
268
|
-
}
|
|
274
|
+
}
|
|
275
|
+
/** Absolute path of a plan file inside the session directory. */
|
|
276
|
+
export function planFilePath(cwd: string, filename: string): string {
|
|
277
|
+
return join(cwd, PLAN_DIR, filename);
|
|
278
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
type Answer,
|
|
24
24
|
type Question,
|
|
25
|
+
type QuestionCheck,
|
|
25
26
|
QuestionnaireParams,
|
|
26
27
|
type QuestionnaireResult,
|
|
27
28
|
type RenderOption,
|
|
@@ -42,7 +43,7 @@ export function errorResult(
|
|
|
42
43
|
/** Validate the question list. Returns the first error message, or null when valid.
|
|
43
44
|
* Blank ids are rejected (answers are keyed by id and "" would produce an empty key),
|
|
44
45
|
* as are duplicate ids and questions with nothing selectable. */
|
|
45
|
-
export function validateQuestions(questions:
|
|
46
|
+
export function validateQuestions(questions: readonly QuestionCheck[]): string | null {
|
|
46
47
|
const seenIds = new Set<string>();
|
|
47
48
|
for (const q of questions) {
|
|
48
49
|
if (q.id.trim() === "") {
|
|
@@ -303,8 +304,10 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
303
304
|
const tabs: string[] = ["← "];
|
|
304
305
|
for (let i = 0; i < questions.length; i++) {
|
|
305
306
|
const isActive = i === currentTab;
|
|
306
|
-
const
|
|
307
|
-
|
|
307
|
+
const question = questions[i];
|
|
308
|
+
if (!question) continue;
|
|
309
|
+
const isAnswered = answers.has(question.id);
|
|
310
|
+
const lbl = question.label;
|
|
308
311
|
const box = isAnswered ? "■" : "□";
|
|
309
312
|
const color = isAnswered ? "success" : "muted";
|
|
310
313
|
const text = ` ${box} ${lbl} `;
|
|
@@ -326,6 +329,7 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
326
329
|
function renderOptions() {
|
|
327
330
|
for (let i = 0; i < opts.length; i++) {
|
|
328
331
|
const opt = opts[i];
|
|
332
|
+
if (!opt) continue; // a hole in the option list renders nothing rather than throwing mid-frame
|
|
329
333
|
const selected = i === optionIndex;
|
|
330
334
|
const isOther = opt.isOther === true;
|
|
331
335
|
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
@@ -435,7 +439,9 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
435
439
|
},
|
|
436
440
|
|
|
437
441
|
renderResult(result, _options, theme, _context) {
|
|
438
|
-
|
|
442
|
+
// `isError` on tool results is newer than the declared floor: pi 0.87.x does not carry
|
|
443
|
+
// it in its types, and an older host leaves it undefined, which reads as "not an error".
|
|
444
|
+
if ((result as { isError?: boolean }).isError) {
|
|
439
445
|
const text = result.content[0];
|
|
440
446
|
return new Text(theme.fg("error", text?.type === "text" ? text.text : ""), 0, 0);
|
|
441
447
|
}
|
|
@@ -18,6 +18,10 @@ export interface Question {
|
|
|
18
18
|
allowOther: boolean;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** The fields `validateQuestions` reads: the same shape on raw tool params (where `label`
|
|
22
|
+
* still has to be defaulted) and on normalized questions. */
|
|
23
|
+
export type QuestionCheck = Pick<Question, "id" | "options"> & Partial<Pick<Question, "allowOther">>;
|
|
24
|
+
|
|
21
25
|
export interface Answer {
|
|
22
26
|
id: string;
|
|
23
27
|
value: string;
|
|
@@ -32,7 +32,7 @@ simplebench({ model: "global.openai.gpt-5.6-terra", coding_lite: true })
|
|
|
32
32
|
simplebench({ model: "global.openai.gpt-5.6-terra", test_all: true })
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`--no-artifact` and `no_artifact: true` suppress the JSON file. Default runs use the provider's sampling and reasoning defaults and write one JSON report per model to
|
|
35
|
+
`--no-artifact` and `no_artifact: true` suppress the JSON file. Default runs use the provider's sampling and reasoning defaults and write one JSON report per model to the current Pi session directory (`ExtensionContext.cwd`). Without an active session context, artifacts fall back to the harness working directory. `--thinking-max` and `thinking_max: true` request `reasoning_effort: "max"` for OpenAI-compatible providers, or Pi's model-aware Bedrock max-thinking path for direct Bedrock models that advertise `reasoning: true` and `thinkingLevelMap.max`. Other providers reject that mode rather than silently using defaults.
|
|
36
36
|
|
|
37
37
|
## Extension features
|
|
38
38
|
|
|
@@ -172,7 +172,7 @@ The stats endpoint is metadata-only and does not change inference routing. Its s
|
|
|
172
172
|
|
|
173
173
|
## Artifacts and privacy
|
|
174
174
|
|
|
175
|
-
Baseline and coding-lite artifacts
|
|
175
|
+
Baseline and coding-lite artifacts use the current Pi session directory (`ExtensionContext.cwd`), passed down explicitly; without a Pi session, they fall back to the harness working directory. `--test-all` writes a bundle:
|
|
176
176
|
|
|
177
177
|
```text
|
|
178
178
|
simplebench--test-all-<sanitized-model>-<thinking>-<UTC-timestamp>/
|
|
@@ -211,6 +211,8 @@ The model receives weather and calculation tools. Simplebench validates expected
|
|
|
211
211
|
|
|
212
212
|
Coding-lite is the execution-backed coding-agent suite. It runs model-directed edits in temporary directories. The model can list, search, read, write, and run the fixture's public tests, but cannot execute arbitrary commands, access the real repository, read hidden verification code, or use the network. The host runs hidden verification after the model stops.
|
|
213
213
|
|
|
214
|
+
**The verifier executes model-authored code.** Verification runs `node --eval` over the model's files, so that process is started with an allowlisted environment (`util/exec-env.ts`): `PATH`, `HOME`, temp-directory and locale variables, TLS trust paths, terminal capability flags, and the Windows equivalents. Everything else is absent — every `*_API_KEY`, `AWS_*`, `GH_*`, `PI_*`, and any other secret your shell happens to export — and `NODE_OPTIONS`/`NODE_PATH` are never forwarded because both can make Node load attacker-chosen files. Anything the model's code spawns inherits that environment, so a nested `node src/cli.mjs` sees the same allowlist. This limits what generated code can *read out of the process*; it is not sandboxing. The child still runs with your user's filesystem permissions and network reach, and a `HOME` it can see points at files it could read by path anyway. Use `--no-artifact` and a dedicated shell if that is not acceptable for your environment.
|
|
215
|
+
|
|
214
216
|
The agent loop defaults to **5 turns** per task (reduced from 12). This tighter budget forces the model to converge on a solution quickly and makes turn count a meaningful quality signal. Results are scored on three dimensions:
|
|
215
217
|
|
|
216
218
|
- **STRONG** — solved in 1–2 turns (first or second attempt correct)
|
|
@@ -230,17 +232,17 @@ Use `--coding-lite` for coding tasks only. Use `--test-all` to run the existing
|
|
|
230
232
|
|
|
231
233
|
### Recommendation policy
|
|
232
234
|
|
|
233
|
-
|
|
235
|
+
Four categories can be in play: the closed-answer contract, instruction following, tool usage, and (with `--coding-lite`) coding. A category counts as passed when its own rule succeeds - the closed-answer contract counts as passed at `STRONG` **or** `MODERATE`, and coding counts as passed on its efficiency-weighted average. The label then depends on how many passed, and on how many categories actually ran:
|
|
234
236
|
|
|
235
|
-
|
|
|
236
|
-
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
|
241
|
-
|
|
|
237
|
+
| Categories passed | Baseline (3 categories) | With coding (4 categories) |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| All | STRONG if the contract is `STRONG` and, when coding ran, every task is `STRONG`; GOOD otherwise | |
|
|
240
|
+
| All but one | GOOD | GOOD |
|
|
241
|
+
| Two | USABLE | LIMITED |
|
|
242
|
+
| One | LIMITED | LIMITED |
|
|
243
|
+
| None | WEAK | WEAK |
|
|
242
244
|
|
|
243
|
-
|
|
245
|
+
`WEAK` therefore requires that **nothing** passed; one passing category is `LIMITED`. A run that omits coding can report `USABLE` where the same results with coding report `LIMITED`, because the denominator changes. For runs with no clean category counts (an error or a cancelled run), `report.ts` falls back to the average `tests[].score`: `>= 0.8` `GOOD`, `>= 0.5` `USABLE`, `>= 0.2` `LIMITED`, otherwise `WEAK`. The coding category is scored efficiency-weighted (STRONG=1.0, MODERATE=0.7, WEAK=0.4, FAIL=0) and passes when that weighted average is >= 0.5 across all tasks.
|
|
244
246
|
|
|
245
247
|
## Provider authentication
|
|
246
248
|
|
|
@@ -260,6 +262,8 @@ Simplebench derives the region from the selected model's Bedrock base URL, signs
|
|
|
260
262
|
aws configure export-credentials --profile "$AWS_PROFILE"
|
|
261
263
|
```
|
|
262
264
|
|
|
265
|
+
The CLI is invoked with an argument vector, never through a shell, so the profile value is never interpolated into a command string; and it runs asynchronously, so a slow SSO or assume-role chain cannot stall the TUI. A profile name that is not a plain token (`^[A-Za-z0-9][A-Za-z0-9_.:@+-]{0,127}$`) is refused before any process is spawned — `AWS_PROFILE` set by repo tooling such as `.envrc` or a Makefile is not trusted to reach the CLI. Set `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN`) to bypass the CLI entirely.
|
|
266
|
+
|
|
263
267
|
AWS CLI v2 is therefore required when the selected profile uses SSO or an assume-role chain.
|
|
264
268
|
|
|
265
269
|
## Troubleshooting
|
|
@@ -270,7 +274,7 @@ AWS CLI v2 is therefore required when the selected profile uses SSO or an assume
|
|
|
270
274
|
| OpenRouter returns 401 | Set `OPENROUTER_API_KEY`, configure the provider key, or authenticate Pi so `auth.json` has an API key. |
|
|
271
275
|
| Bedrock says credentials are unavailable | Run `aws configure export-credentials --profile "$AWS_PROFILE"` and resolve profile login/role issues first. |
|
|
272
276
|
| Bedrock rejects `--thinking-max` | The selected model does not advertise max thinking in Pi metadata, or the provider rejected its model-specific reasoning request. |
|
|
273
|
-
| Artifact is absent | Check the report for `--no-artifact` or an artifact-write warning; the file
|
|
277
|
+
| Artifact is absent | Check the report for `--no-artifact` or an artifact-write warning; the file lands in the session directory, not where the shell was standing. |
|
|
274
278
|
| Token or TTFT values are null | The provider response did not expose authoritative usage/timing values. |
|
|
275
279
|
|
|
276
280
|
## Architecture
|
|
@@ -279,13 +283,15 @@ AWS CLI v2 is therefore required when the selected profile uses SSO or an assume
|
|
|
279
283
|
index.ts Pi registration, command parsing, tool schema
|
|
280
284
|
benchmark.ts provider adapters and benchmark orchestration
|
|
281
285
|
llama-server.ts direct llama-server /props and /metrics capture
|
|
286
|
+
coding.ts the six execution-backed coding tasks and their agent loop
|
|
287
|
+
research.ts grounded research fixtures and the deterministic verifier
|
|
282
288
|
tests.ts benchmark fixtures
|
|
283
289
|
scoring.ts extraction and scoring
|
|
284
290
|
metrics.ts request/usage aggregation
|
|
285
|
-
artifact.ts
|
|
291
|
+
artifact.ts session-directory JSON artifact writer
|
|
286
292
|
report.ts terminal output and recommendation policy
|
|
287
293
|
types.ts shared benchmark types
|
|
288
|
-
util/ local configuration, formatting, debugging, and provider helpers
|
|
294
|
+
util/ local configuration, formatting, debugging, exec-environment scrubbing, and provider helpers
|
|
289
295
|
```
|
|
290
296
|
|
|
291
|
-
|
|
297
|
+
Everything lives under `extensions/opl-simplebench/` because `install.sh` installs one extension directory at a time; the only environment boundary model-authored code gets is `util/exec-env.ts`, which is where a new child process must go through.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
// The output directory is always passed in: artifacts land in the session directory
|
|
5
|
+
// (`ExtensionContext.cwd`), not wherever the harness was started.
|
|
3
6
|
import type { RunArtifact } from "./types";
|
|
4
7
|
|
|
5
8
|
export function artifactFileName(model: string, suite: "baseline" | "coding-lite" | "test-all" = "baseline", thinking: "default" | "max" = "default", date = new Date(), tag?: string): string {
|
|
@@ -9,10 +12,10 @@ export function artifactFileName(model: string, suite: "baseline" | "coding-lite
|
|
|
9
12
|
return `simplebench${tag ? `-${tag}-` : "--"}${suiteName}-${safeModel}-${thinking}-${stamp}.json`;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
export function writeArtifactBundle(artifact: RunArtifact, files: Record<string, string
|
|
15
|
+
export function writeArtifactBundle(artifact: RunArtifact, files: Record<string, string>, cwd: string): string {
|
|
13
16
|
const name = artifactFileName(artifact.benchmark.model, artifact.benchmark.suite || "baseline", artifact.benchmark.thinking.requested, new Date(), artifact.benchmark.tag).replace(/\.json$/, "");
|
|
14
17
|
for (let suffix = 0; ; suffix += 1) {
|
|
15
|
-
const output = path.resolve(
|
|
18
|
+
const output = path.resolve(cwd, suffix ? `${name}-${suffix}` : name);
|
|
16
19
|
try {
|
|
17
20
|
fs.mkdirSync(output);
|
|
18
21
|
fs.writeFileSync(path.join(output, "result.json"), JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
@@ -24,13 +27,13 @@ export function writeArtifactBundle(artifact: RunArtifact, files: Record<string,
|
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
export function writeArtifact(artifact: RunArtifact): string {
|
|
30
|
+
export function writeArtifact(artifact: RunArtifact, cwd: string): string {
|
|
28
31
|
const name = artifactFileName(artifact.benchmark.model, artifact.benchmark.suite || "baseline", artifact.benchmark.thinking.requested, new Date(), artifact.benchmark.tag);
|
|
29
32
|
const extension = path.extname(name);
|
|
30
33
|
const base = name.slice(0, -extension.length);
|
|
31
34
|
const contents = JSON.stringify(artifact, null, 2) + "\n";
|
|
32
35
|
for (let suffix = 0; ; suffix += 1) {
|
|
33
|
-
const output = path.resolve(
|
|
36
|
+
const output = path.resolve(cwd, suffix ? `${base}-${suffix}${extension}` : name);
|
|
34
37
|
try {
|
|
35
38
|
fs.writeFileSync(output, contents, { encoding: "utf8", flag: "wx" });
|
|
36
39
|
return output;
|