@openlines/opl-pi-sht 0.1.14

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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/configs/opl-browser.json.sample +8 -0
  4. package/configs/opl-footer.json +48 -0
  5. package/configs/opl-footer.json.sample +48 -0
  6. package/configs/opl-input.json +22 -0
  7. package/configs/opl-input.json.sample +22 -0
  8. package/configs/opl-modes.json +291 -0
  9. package/configs/opl-modes.json.sample +285 -0
  10. package/configs/opl-simplebench.json +8 -0
  11. package/configs/opl-simplebench.json.sample +8 -0
  12. package/configs/opl-todo.json +12 -0
  13. package/configs/opl-todo.json.sample +12 -0
  14. package/configs/opl-webaccess.json +31 -0
  15. package/configs/opl-webaccess.json.sample +31 -0
  16. package/extensions/opl-browser/README.md +83 -0
  17. package/extensions/opl-browser/browser.ts +185 -0
  18. package/extensions/opl-browser/config.ts +37 -0
  19. package/extensions/opl-browser/index.ts +89 -0
  20. package/extensions/opl-browser/package-lock.json +60 -0
  21. package/extensions/opl-browser/package.json +10 -0
  22. package/extensions/opl-ctxtrim/README.md +78 -0
  23. package/extensions/opl-ctxtrim/index.ts +151 -0
  24. package/extensions/opl-footer/README.md +193 -0
  25. package/extensions/opl-footer/config.ts +91 -0
  26. package/extensions/opl-footer/git-status.ts +170 -0
  27. package/extensions/opl-footer/icons.ts +49 -0
  28. package/extensions/opl-footer/index.ts +368 -0
  29. package/extensions/opl-footer/segments/caveman.ts +25 -0
  30. package/extensions/opl-footer/segments/chat-mode.ts +24 -0
  31. package/extensions/opl-footer/segments/context.ts +92 -0
  32. package/extensions/opl-footer/segments/cost.ts +25 -0
  33. package/extensions/opl-footer/segments/git.ts +47 -0
  34. package/extensions/opl-footer/segments/helpers.ts +18 -0
  35. package/extensions/opl-footer/segments/index.ts +51 -0
  36. package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
  37. package/extensions/opl-footer/segments/model.ts +22 -0
  38. package/extensions/opl-footer/segments/path.ts +34 -0
  39. package/extensions/opl-footer/segments/pi.ts +11 -0
  40. package/extensions/opl-footer/segments/plan-mode.ts +24 -0
  41. package/extensions/opl-footer/segments/separator.ts +9 -0
  42. package/extensions/opl-footer/segments/session-stats.ts +82 -0
  43. package/extensions/opl-footer/segments/thinking.ts +51 -0
  44. package/extensions/opl-footer/segments/tokens.ts +66 -0
  45. package/extensions/opl-footer/theme.ts +113 -0
  46. package/extensions/opl-footer/types.ts +172 -0
  47. package/extensions/opl-init/README.md +23 -0
  48. package/extensions/opl-init/index.ts +451 -0
  49. package/extensions/opl-input/README.md +83 -0
  50. package/extensions/opl-input/config.ts +137 -0
  51. package/extensions/opl-input/index.ts +284 -0
  52. package/extensions/opl-input/mode-style.ts +38 -0
  53. package/extensions/opl-input/utils.ts +378 -0
  54. package/extensions/opl-modes/README.md +132 -0
  55. package/extensions/opl-modes/config.ts +473 -0
  56. package/extensions/opl-modes/index.ts +1081 -0
  57. package/extensions/opl-modes/menus.ts +60 -0
  58. package/extensions/opl-modes/state.ts +152 -0
  59. package/extensions/opl-modes/types.ts +164 -0
  60. package/extensions/opl-modes/utils.ts +138 -0
  61. package/extensions/opl-questionnaire/README.md +26 -0
  62. package/extensions/opl-questionnaire/index.ts +445 -0
  63. package/extensions/opl-questionnaire/types.ts +55 -0
  64. package/extensions/opl-simplebench/README.md +242 -0
  65. package/extensions/opl-simplebench/artifact.ts +41 -0
  66. package/extensions/opl-simplebench/benchmark.ts +873 -0
  67. package/extensions/opl-simplebench/coding.ts +282 -0
  68. package/extensions/opl-simplebench/index.ts +203 -0
  69. package/extensions/opl-simplebench/llama-server.ts +290 -0
  70. package/extensions/opl-simplebench/metrics.ts +73 -0
  71. package/extensions/opl-simplebench/report.ts +46 -0
  72. package/extensions/opl-simplebench/research.ts +200 -0
  73. package/extensions/opl-simplebench/scoring.ts +48 -0
  74. package/extensions/opl-simplebench/tests.ts +39 -0
  75. package/extensions/opl-simplebench/types.ts +6 -0
  76. package/extensions/opl-simplebench/util/config.ts +170 -0
  77. package/extensions/opl-simplebench/util/debug.ts +32 -0
  78. package/extensions/opl-simplebench/util/format.ts +402 -0
  79. package/extensions/opl-simplebench/util/providers.ts +841 -0
  80. package/extensions/opl-todo/README.md +43 -0
  81. package/extensions/opl-todo/config.ts +71 -0
  82. package/extensions/opl-todo/index.ts +415 -0
  83. package/extensions/opl-webaccess/README.md +47 -0
  84. package/extensions/opl-webaccess/config.ts +55 -0
  85. package/extensions/opl-webaccess/extract.ts +83 -0
  86. package/extensions/opl-webaccess/index.ts +232 -0
  87. package/extensions/opl-webaccess/package-lock.json +345 -0
  88. package/extensions/opl-webaccess/package.json +18 -0
  89. package/extensions/opl-webaccess/pdf.ts +8 -0
  90. package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
  91. package/extensions/opl-webaccess/providers/exa.ts +74 -0
  92. package/extensions/opl-webaccess/providers/gemini.ts +60 -0
  93. package/extensions/opl-webaccess/providers/searxng.ts +86 -0
  94. package/extensions/opl-webaccess/providers/tavily.ts +60 -0
  95. package/extensions/opl-webaccess/search.ts +36 -0
  96. package/extensions/opl-webaccess/storage.ts +51 -0
  97. package/extensions/opl-webaccess/turndown.d.ts +1 -0
  98. package/extensions/opl-webaccess/types.ts +26 -0
  99. package/extensions/opl-webaccess/utils.ts +28 -0
  100. package/package.json +55 -0
@@ -0,0 +1,43 @@
1
+ # opl-todo
2
+
3
+ Provides branch-aware model-managed tasks with a non-capturing overlay and full-screen `/todos` view.
4
+
5
+ ## Commands, flags, and shortcuts
6
+
7
+ - `/todos`: open the current branch's full-screen task list; `Esc` closes it.
8
+ - Tool: `todo` with `list`, `add`, `toggle`, and `clear` actions.
9
+ - `shortcuts.toggleWidget`: show or hide the overlay, default `ctrl+alt+t`.
10
+ - `shortcuts.resetDone`: clear only a fully completed list, default `ctrl+alt+r`.
11
+
12
+ ## Extension features
13
+
14
+ - Stores todos in session tool results; switching session branches with `/tree` restores that branch's list.
15
+ - Starts a fresh batch at ID `#1` when adding after every prior task is complete.
16
+ - Displays a top-right overlay without taking editor focus; hides below 80 columns and caps its height to the configured terminal percentage.
17
+ - Shows `✓ All done!` and hides after the configured delay; `clear` hides immediately.
18
+ - Reconstructs state on session start, `/reload`, and session-tree changes. A completed restored list is cleared so the next add begins a new batch.
19
+
20
+ ## Configuration
21
+
22
+ Copy [`configs/opl-todo.json.sample`](../../configs/opl-todo.json.sample) to `~/.pi/agent/configs/opl-todo.json`, then `/reload` or restart Pi.
23
+
24
+ ```json
25
+ {
26
+ "allDoneHideMs": 10000,
27
+ "shortcuts": { "toggleWidget": "ctrl+alt+t", "resetDone": "ctrl+alt+r" },
28
+ "widget": { "widthPercent": 33, "maxHeightPercent": 50, "minWidth": 32 }
29
+ }
30
+ ```
31
+
32
+ | Key | Default | Behavior |
33
+ |---|---:|---|
34
+ | `allDoneHideMs` | `5000` | Delay before a completed overlay hides; `0` hides immediately. |
35
+ | `shortcuts.toggleWidget` | `ctrl+alt+t` | Toggle overlay visibility. |
36
+ | `shortcuts.resetDone` | `ctrl+alt+r` | Clear only a fully completed list. |
37
+ | `widget.widthPercent` | `33` | Overlay width, greater than 0 through 100. |
38
+ | `widget.maxHeightPercent` | `50` | Terminal-row percentage used for the item cap. |
39
+ | `widget.minWidth` | `32` | Minimum overlay width; values below 20 are rejected. |
40
+
41
+ ## Architecture
42
+
43
+ `index.ts` registers the tool, overlay, shortcuts, command, and session integration. `config.ts` loads and validates JSON. Todo state is derived from session history rather than a separate file.
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface TodoConfig {
6
+ allDoneHideMs: number;
7
+ shortcuts: {
8
+ toggleWidget: string;
9
+ resetDone: string;
10
+ };
11
+ widget: {
12
+ widthPercent: number;
13
+ maxHeightPercent: number;
14
+ minWidth: number;
15
+ };
16
+ }
17
+
18
+ const DEFAULT: TodoConfig = {
19
+ allDoneHideMs: 5000,
20
+ shortcuts: {
21
+ toggleWidget: "ctrl+alt+t",
22
+ resetDone: "ctrl+alt+r",
23
+ },
24
+ widget: {
25
+ widthPercent: 33,
26
+ maxHeightPercent: 50,
27
+ minWidth: 32,
28
+ },
29
+ };
30
+
31
+ function load(path = join(homedir(), ".pi", "agent", "configs", "opl-todo.json")): TodoConfig {
32
+ try {
33
+ if (!existsSync(path)) return DEFAULT;
34
+ const raw = JSON.parse(readFileSync(path, "utf8"));
35
+ return {
36
+ allDoneHideMs:
37
+ typeof raw.allDoneHideMs === "number" && raw.allDoneHideMs >= 0
38
+ ? raw.allDoneHideMs
39
+ : DEFAULT.allDoneHideMs,
40
+ shortcuts: {
41
+ toggleWidget:
42
+ typeof raw.shortcuts?.toggleWidget === "string"
43
+ ? raw.shortcuts.toggleWidget
44
+ : DEFAULT.shortcuts.toggleWidget,
45
+ resetDone:
46
+ typeof raw.shortcuts?.resetDone === "string"
47
+ ? raw.shortcuts.resetDone
48
+ : DEFAULT.shortcuts.resetDone,
49
+ },
50
+ widget: {
51
+ widthPercent:
52
+ typeof raw.widget?.widthPercent === "number" && raw.widget.widthPercent > 0 && raw.widget.widthPercent <= 100
53
+ ? raw.widget.widthPercent
54
+ : DEFAULT.widget.widthPercent,
55
+ maxHeightPercent:
56
+ typeof raw.widget?.maxHeightPercent === "number" && raw.widget.maxHeightPercent > 0 && raw.widget.maxHeightPercent <= 100
57
+ ? raw.widget.maxHeightPercent
58
+ : DEFAULT.widget.maxHeightPercent,
59
+ minWidth:
60
+ typeof raw.widget?.minWidth === "number" && raw.widget.minWidth >= 20
61
+ ? raw.widget.minWidth
62
+ : DEFAULT.widget.minWidth,
63
+ },
64
+ };
65
+ } catch {
66
+ return DEFAULT;
67
+ }
68
+ }
69
+
70
+ export const CONFIG = load();
71
+ export { load as loadTodoConfig, DEFAULT as TODO_DEFAULT_CONFIG };
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Todo Extension - State management via session entries + persistent overlay widget
3
+ *
4
+ * See README.md for full documentation.
5
+ */
6
+
7
+ import { StringEnum } from "@earendil-works/pi-ai";
8
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
9
+ import { matchesKey, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import type { KeyId, OverlayHandle, TUI } from "@earendil-works/pi-tui";
11
+ import { Type } from "typebox";
12
+ import { CONFIG } from "./config.js";
13
+
14
+ interface Todo {
15
+ id: number;
16
+ text: string;
17
+ done: boolean;
18
+ }
19
+
20
+ interface TodoDetails {
21
+ action: "list" | "add" | "toggle" | "clear";
22
+ todos: Todo[];
23
+ nextId: number;
24
+ error?: string;
25
+ }
26
+
27
+ // ── Widget constants ─────────────────────────────────────────────────────────
28
+
29
+ const TodoParams = Type.Object({
30
+ action: StringEnum(["list", "add", "toggle", "clear"] as const),
31
+ text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
32
+ id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
33
+ });
34
+
35
+ /**
36
+ * Persistent top-right overlay. Non-capturing — never steals editor focus.
37
+ * Reads the shared `todos` array directly so render() always reflects live state.
38
+ */
39
+ class TodoWidgetComponent {
40
+ constructor(
41
+ private readonly todos: Todo[],
42
+ private readonly theme: Theme,
43
+ ) {}
44
+
45
+ render(width: number): string[] {
46
+ const th = this.theme;
47
+ const t = this.todos;
48
+ const maxItems = Math.max(3, Math.floor((process.stdout.rows || 24) * CONFIG.widget.maxHeightPercent / 100) - 5);
49
+ const W = Math.max(width, CONFIG.widget.minWidth);
50
+
51
+ const b = (s: string) => th.fg("border", s);
52
+ const dim = (s: string) => th.fg("dim", s);
53
+
54
+ const innerW = W - 4;
55
+ const contentRow = (content: string): string => {
56
+ const vis = visibleWidth(content);
57
+ const padding = " ".repeat(Math.max(0, innerW - vis));
58
+ return b("│") + " " + truncateToWidth(content, innerW) + padding + " " + b("│");
59
+ };
60
+
61
+ const lines: string[] = [];
62
+
63
+ const titleVis = 9; // " ☑ Todos " = 9 visible chars
64
+ const topFill = "─".repeat(Math.max(0, W - 2 - titleVis));
65
+ lines.push(
66
+ b("╭") +
67
+ " " + th.fg("accent", "☑") + " " + th.fg("accent", "Todos") + " " +
68
+ b(topFill + "╮"),
69
+ );
70
+
71
+ if (t.length === 0) {
72
+ lines.push(contentRow(dim("No active todos")));
73
+ } else {
74
+ for (const todo of t.slice(0, maxItems)) {
75
+ const check = todo.done ? th.fg("success", "✓") : dim("○");
76
+ const id = th.fg("accent", `#${todo.id}`);
77
+ const text = todo.done ? dim(todo.text) : todo.text;
78
+ lines.push(contentRow(`${check} ${id} ${text}`));
79
+ }
80
+ if (t.length > maxItems) {
81
+ lines.push(contentRow(dim(`… +${t.length - maxItems} more`)));
82
+ }
83
+ const done = t.filter((x) => x.done).length;
84
+ const prog = done === t.length
85
+ ? th.fg("success", "✓ All done!")
86
+ : dim(`${done}/${t.length} completed`);
87
+ lines.push(contentRow(prog));
88
+ }
89
+
90
+ const resetStr = ` ${CONFIG.shortcuts.resetDone} `;
91
+ const toggleStr = ` ${CONFIG.shortcuts.toggleWidget} `;
92
+ const botFill = "─".repeat(Math.max(0, W - 2 - resetStr.length - toggleStr.length));
93
+ lines.push(b("╰") + dim(resetStr) + b(botFill) + dim(toggleStr) + b("╯"));
94
+
95
+ return lines;
96
+ }
97
+
98
+ invalidate(): void {}
99
+ }
100
+
101
+ /**
102
+ * Modal list component for the /todos command.
103
+ */
104
+ class TodoListComponent {
105
+ private cachedWidth?: number;
106
+ private cachedLines?: string[];
107
+
108
+ constructor(
109
+ private readonly todos: Todo[],
110
+ private readonly theme: Theme,
111
+ private readonly onClose: () => void,
112
+ ) {}
113
+
114
+ handleInput(data: string): void {
115
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
116
+ this.onClose();
117
+ }
118
+ }
119
+
120
+ render(width: number): string[] {
121
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
122
+
123
+ const lines: string[] = [];
124
+ const th = this.theme;
125
+
126
+ lines.push("");
127
+ const title = th.fg("accent", " Todos ");
128
+ const headerLine =
129
+ th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
130
+ lines.push(truncateToWidth(headerLine, width));
131
+ lines.push("");
132
+
133
+ if (this.todos.length === 0) {
134
+ lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
135
+ } else {
136
+ const done = this.todos.filter((t) => t.done).length;
137
+ const total = this.todos.length;
138
+ lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
139
+ lines.push("");
140
+ for (const todo of this.todos) {
141
+ const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
142
+ const id = th.fg("accent", `#${todo.id}`);
143
+ const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
144
+ lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
145
+ }
146
+ }
147
+
148
+ lines.push("");
149
+ lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
150
+ lines.push("");
151
+
152
+ this.cachedWidth = width;
153
+ this.cachedLines = lines;
154
+ return lines;
155
+ }
156
+
157
+ invalidate(): void {
158
+ this.cachedWidth = undefined;
159
+ this.cachedLines = undefined;
160
+ }
161
+ }
162
+
163
+ export default function (pi: ExtensionAPI) {
164
+ // ── Shared state ─────────────────────────────────────────────────────────────
165
+ const todos: Todo[] = [];
166
+ let nextId = 1;
167
+
168
+ // ── Widget handles ─────────────────────────────────────────────────────
169
+ let overlayHandle: OverlayHandle | undefined;
170
+ let tuiRef: TUI | undefined;
171
+ let autoHideTimer: ReturnType<typeof setTimeout> | undefined;
172
+
173
+ function showWidget(): void {
174
+ clearTimeout(autoHideTimer);
175
+ overlayHandle?.setHidden(false);
176
+ tuiRef?.requestRender();
177
+ }
178
+ function hideWidget(): void {
179
+ clearTimeout(autoHideTimer);
180
+ overlayHandle?.setHidden(true);
181
+ tuiRef?.requestRender();
182
+ }
183
+ function scheduleAutoHide(): void {
184
+ clearTimeout(autoHideTimer);
185
+ autoHideTimer = setTimeout(() => {
186
+ overlayHandle?.setHidden(true);
187
+ tuiRef?.requestRender();
188
+ }, CONFIG.allDoneHideMs);
189
+ }
190
+
191
+ // ── State reconstruction ──────────────────────────────────────────────
192
+ const reconstructState = (ctx: ExtensionContext) => {
193
+ todos.length = 0;
194
+ nextId = 1;
195
+
196
+ for (const entry of ctx.sessionManager.getBranch()) {
197
+ if (entry.type !== "message") continue;
198
+ const msg = entry.message;
199
+ if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
200
+ const details = msg.details as TodoDetails | undefined;
201
+ if (details) {
202
+ todos.length = 0;
203
+ todos.push(...details.todos);
204
+ nextId = details.nextId;
205
+ }
206
+ }
207
+
208
+ // Auto-clear on load/resume if everything was already finished
209
+ if (todos.length > 0 && todos.every((t) => t.done)) {
210
+ todos.length = 0;
211
+ nextId = 1;
212
+ }
213
+
214
+ if (overlayHandle) {
215
+ overlayHandle.setHidden(todos.length === 0);
216
+ tuiRef?.requestRender();
217
+ }
218
+ };
219
+
220
+ // ── Session events ──────────────────────────────────────────────────
221
+ pi.on("session_start", async (_event, ctx) => {
222
+ reconstructState(ctx);
223
+ if (ctx.mode !== "tui") return;
224
+
225
+ void ctx.ui.custom<void>(
226
+ (tui, theme, _kb, _done) => {
227
+ tuiRef = tui;
228
+ return new TodoWidgetComponent(todos, theme);
229
+ },
230
+ {
231
+ overlay: true,
232
+ overlayOptions: {
233
+ anchor: "top-right",
234
+ offsetX: -1,
235
+ offsetY: 0,
236
+ width: `${CONFIG.widget.widthPercent}%`,
237
+ nonCapturing: true,
238
+ visible: (w) => w >= 80,
239
+ },
240
+ onHandle: (h) => {
241
+ overlayHandle = h;
242
+ h.setHidden(todos.length === 0);
243
+ },
244
+ },
245
+ );
246
+ });
247
+
248
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
249
+
250
+ // ── Shortcuts ────────────────────────────────────────────────────────────
251
+ pi.registerShortcut(CONFIG.shortcuts.toggleWidget as KeyId, {
252
+ description: "Toggle todo widget",
253
+ handler: (_ctx) => {
254
+ if (!overlayHandle) return;
255
+ if (overlayHandle.isHidden()) showWidget();
256
+ else hideWidget();
257
+ },
258
+ });
259
+
260
+ pi.registerShortcut(CONFIG.shortcuts.resetDone as KeyId, {
261
+ description: "Clear completed todos (only when all are done)",
262
+ handler: (_ctx) => {
263
+ if (todos.length === 0 || !todos.every((t) => t.done)) return;
264
+ todos.length = 0;
265
+ nextId = 1;
266
+ hideWidget();
267
+ },
268
+ });
269
+
270
+ // ── Tool ───────────────────────────────────────────────────────────────────
271
+ pi.registerTool({
272
+ name: "todo",
273
+ label: "Todo",
274
+ description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
275
+ parameters: TodoParams,
276
+
277
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
278
+ switch (params.action) {
279
+ case "list":
280
+ return {
281
+ content: [{
282
+ type: "text",
283
+ text: todos.length
284
+ ? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
285
+ : "No todos",
286
+ }],
287
+ details: { action: "list", todos: [...todos], nextId } as TodoDetails,
288
+ };
289
+
290
+ case "add": {
291
+ if (!params.text) {
292
+ return {
293
+ content: [{ type: "text", text: "Error: text required for add" }],
294
+ details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
295
+ };
296
+ }
297
+ // Fresh batch: if all existing tasks are done, clear them first
298
+ if (todos.length > 0 && todos.every((t) => t.done)) {
299
+ todos.length = 0;
300
+ nextId = 1;
301
+ clearTimeout(autoHideTimer);
302
+ }
303
+ const cleanText = params.text.replace(/^step\s*\d+[:.\s-]+/i, "").trim();
304
+ const newTodo: Todo = { id: nextId++, text: cleanText, done: false };
305
+ todos.push(newTodo);
306
+ showWidget();
307
+ return {
308
+ content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
309
+ details: { action: "add", todos: [...todos], nextId } as TodoDetails,
310
+ };
311
+ }
312
+
313
+ case "toggle": {
314
+ if (params.id === undefined) {
315
+ return {
316
+ content: [{ type: "text", text: "Error: id required for toggle" }],
317
+ details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
318
+ };
319
+ }
320
+ const todo = todos.find((t) => t.id === params.id);
321
+ if (!todo) {
322
+ return {
323
+ content: [{ type: "text", text: `Todo #${params.id} not found` }],
324
+ details: { action: "toggle", todos: [...todos], nextId, error: `#${params.id} not found` } as TodoDetails,
325
+ };
326
+ }
327
+ todo.done = !todo.done;
328
+ showWidget();
329
+ if (todos.every((t) => t.done)) scheduleAutoHide();
330
+ return {
331
+ content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
332
+ details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
333
+ };
334
+ }
335
+
336
+ case "clear": {
337
+ const count = todos.length;
338
+ todos.length = 0;
339
+ nextId = 1;
340
+ hideWidget();
341
+ return {
342
+ content: [{ type: "text", text: `Cleared ${count} todos` }],
343
+ details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
344
+ };
345
+ }
346
+
347
+ default:
348
+ return {
349
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
350
+ details: { action: "list", todos: [...todos], nextId, error: `unknown: ${params.action}` } as TodoDetails,
351
+ };
352
+ }
353
+ },
354
+
355
+ renderCall(args, theme, _context) {
356
+ let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
357
+ if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
358
+ if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
359
+ return new Text(text, 0, 0);
360
+ },
361
+
362
+ renderResult(result, { expanded }, theme, _context) {
363
+ const details = result.details as TodoDetails | undefined;
364
+ if (!details) {
365
+ const text = result.content[0];
366
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
367
+ }
368
+ if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
369
+
370
+ const todoList = details.todos;
371
+ switch (details.action) {
372
+ case "list": {
373
+ if (todoList.length === 0) return new Text(theme.fg("dim", "No todos"), 0, 0);
374
+ let listText = theme.fg("muted", `${todoList.length} todo(s):`);
375
+ const display = expanded ? todoList : todoList.slice(0, 5);
376
+ for (const t of display) {
377
+ const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
378
+ const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
379
+ listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
380
+ }
381
+ if (!expanded && todoList.length > 5) listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
382
+ return new Text(listText, 0, 0);
383
+ }
384
+ case "add": {
385
+ const added = todoList[todoList.length - 1];
386
+ return new Text(
387
+ theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`) + " " + theme.fg("muted", added.text),
388
+ 0, 0,
389
+ );
390
+ }
391
+ case "toggle": {
392
+ const text = result.content[0];
393
+ const msg = text?.type === "text" ? text.text : "";
394
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
395
+ }
396
+ case "clear":
397
+ return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
398
+ }
399
+ },
400
+ });
401
+
402
+ // ── Command: /todos ──────────────────────────────────────────────────────────────
403
+ pi.registerCommand("todos", {
404
+ description: "Show all todos on the current branch",
405
+ handler: async (_args, ctx) => {
406
+ if (ctx.mode !== "tui") {
407
+ ctx.ui.notify("/todos requires interactive mode", "error");
408
+ return;
409
+ }
410
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
411
+ return new TodoListComponent(todos, theme, () => done());
412
+ });
413
+ },
414
+ });
415
+ }
@@ -0,0 +1,47 @@
1
+ # opl-webaccess
2
+
3
+ Provides configurable web search and readable URL/PDF retrieval with session-backed result recovery.
4
+
5
+ ## Commands, flags, and shortcuts
6
+
7
+ - Tools: `web_search`, `fetch_content`, and `get_search_content`.
8
+ - `web_search` accepts `query` or parallel `queries`.
9
+ - `fetch_content` accepts `url` or `urls` with at most three requests in flight.
10
+ - `get_search_content` uses a prior `responseId`, with `queryIndex`, `urlIndex`, or exact `url` selection.
11
+ - No slash commands or shortcuts.
12
+
13
+ ## Extension features
14
+
15
+ - Searches through `gemini`, `tavily`, `ddgs`, `searxng`, or `exa`; multiple queries run concurrently and results include citations where available.
16
+ - Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, passes PDF responses to the PDF extractor, and returns plain text, Markdown, and JSON directly.
17
+ - Caps initial tool output at 30,000 characters, then keeps it for retrieval for one hour or until the session ends.
18
+ - Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
19
+
20
+ ## Configuration
21
+
22
+ Copy [`configs/opl-webaccess.json.sample`](../../configs/opl-webaccess.json.sample) to `~/.pi/agent/configs/opl-webaccess.json`. The top-level `provider` must name an entry in `providers`; configuration is loaded when search runs.
23
+
24
+ ```json
25
+ {
26
+ "provider": "searxng",
27
+ "providers": {
28
+ "searxng": { "instanceUrl": "http://localhost:8888", "maxResults": 12 },
29
+ "tavily": { "apiKeyEnv": "TAVILY_API_KEY", "maxResults": 5 }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Provider fields include `apiKeyEnv`, `apiUrl`, `baseUrl`, `model`, `maxResults`, `instanceUrl`, `categories`, `safeSearch`, `searchType`, and `includeSummary`. API keys are read only from named environment variables. Missing or invalid config falls back to Gemini defaults; an unknown provider returns an error.
35
+
36
+ Install extraction dependencies before use:
37
+
38
+ ```bash
39
+ cd extensions/opl-webaccess
40
+ npm install
41
+ ```
42
+
43
+ `@mozilla/readability`, `linkedom`, `turndown`, and `unpdf` are runtime dependencies. The extension registers without them, but affected calls fail until installed; `unpdf` loads only for PDFs.
44
+
45
+ ## Architecture
46
+
47
+ `index.ts` registers the tools and session storage. Provider adapters live under `providers/`; `search.ts` dispatches search, `extract.ts` converts readable content, `pdf.ts` handles PDFs, and stored response data backs `get_search_content`. Smoke tests bundle with these runtime dependencies externalized; they do not exercise credentials, network, extraction, or PDF behavior.
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export interface ProviderConfig {
6
+ apiKeyEnv?: string;
7
+ apiUrl?: string;
8
+ baseUrl?: string;
9
+ model?: string;
10
+ maxResults?: number;
11
+ instanceUrl?: string;
12
+ categories?: string;
13
+ safeSearch?: number;
14
+ searchType?: string;
15
+ includeSummary?: boolean;
16
+ }
17
+
18
+ export interface WebAccessConfig {
19
+ provider: string;
20
+ providers: Record<string, ProviderConfig>;
21
+ }
22
+
23
+ const CONFIG_PATH = join(getAgentDir(), "configs", "opl-webaccess.json");
24
+
25
+ const DEFAULT_CONFIG: WebAccessConfig = {
26
+ provider: "gemini",
27
+ providers: {
28
+ gemini: {
29
+ apiKeyEnv: "GEMINI_API_KEY",
30
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
31
+ model: "gemini-2.5-flash-lite",
32
+ },
33
+ },
34
+ };
35
+
36
+ export function loadConfig(): WebAccessConfig {
37
+ if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
38
+ try {
39
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebAccessConfig;
40
+ } catch {
41
+ console.error(`[opl-webaccess] failed to parse ${CONFIG_PATH}, using defaults`);
42
+ return DEFAULT_CONFIG;
43
+ }
44
+ }
45
+
46
+ export function getApiKey(cfg: ProviderConfig): string {
47
+ if (!cfg.apiKeyEnv) return "";
48
+ const key = process.env[cfg.apiKeyEnv]?.trim();
49
+ if (!key) {
50
+ throw new Error(
51
+ `${cfg.apiKeyEnv} is not set. Add it to your shell profile:\n\n export ${cfg.apiKeyEnv}="your-key-here"\n\nNote: avoid running commands that print the full environment (like env, set, printenv) when a model is watching — keys in the shell environment are visible to the bash tool.`
52
+ );
53
+ }
54
+ return key;
55
+ }