@openlines/opl-pi-sht 0.2.12 → 0.3.1
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 +101 -85
- 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-lock.json +3 -3
- 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 +151 -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 +31 -6
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +159 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +209 -105
- 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 +146 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +22 -8
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +87 -28
- 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-lock.json +3 -3
- 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/package.json +19 -12
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* @writtenby VTSTech — https://www.vts-tech.org
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { debugLog } from "./debug";
|
|
12
12
|
|
|
13
13
|
/** Minimal shape of Pi's extension context that provider detection reads. Keeps this module
|
|
@@ -41,11 +41,23 @@ export const EXTENSION_VERSION = "1.3.4";
|
|
|
41
41
|
*
|
|
42
42
|
* @example
|
|
43
43
|
* ```typescript
|
|
44
|
-
*
|
|
44
|
+
* Pi reads it from its own agent directory, so `modelsJsonPath()` resolves it per call
|
|
45
|
+
* and a custom `PI_AGENT_DIR` is honoured.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* console.log(modelsJsonPath());
|
|
45
50
|
* // Output: "/home/user/.pi/agent/models.json"
|
|
46
51
|
* ```
|
|
47
52
|
*/
|
|
48
|
-
export
|
|
53
|
+
export function modelsJsonPath(): string {
|
|
54
|
+
return join(getAgentDir(), "models.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pi's credential store, resolved the same way as {@link modelsJsonPath}. */
|
|
58
|
+
export function authJsonPath(): string {
|
|
59
|
+
return join(getAgentDir(), "auth.json");
|
|
60
|
+
}
|
|
49
61
|
|
|
50
62
|
// ============================================================================
|
|
51
63
|
// Types
|
|
@@ -189,8 +201,9 @@ export function getOllamaBaseUrl(): string {
|
|
|
189
201
|
const now = Date.now();
|
|
190
202
|
if (_ollamaBaseUrlCache && now - _ollamaBaseUrlCache.ts < CACHE_TTL_MS) return _ollamaBaseUrlCache.data;
|
|
191
203
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
const file = modelsJsonPath();
|
|
205
|
+
if (fs.existsSync(file)) {
|
|
206
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
194
207
|
const config = JSON.parse(raw) as PiModelsJson;
|
|
195
208
|
const baseUrl = config?.providers?.["ollama"]?.baseUrl;
|
|
196
209
|
if (baseUrl) {
|
|
@@ -234,8 +247,9 @@ export function readModelsJson(): PiModelsJson {
|
|
|
234
247
|
const now = Date.now();
|
|
235
248
|
if (_modelsJsonCache && now - _modelsJsonCache.ts < CACHE_TTL_MS) return _modelsJsonCache.data;
|
|
236
249
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
250
|
+
const file = modelsJsonPath();
|
|
251
|
+
if (fs.existsSync(file)) {
|
|
252
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
239
253
|
const data = JSON.parse(raw) as PiModelsJson;
|
|
240
254
|
_modelsJsonCache = { data, ts: now };
|
|
241
255
|
return data;
|
|
@@ -246,92 +260,6 @@ export function readModelsJson(): PiModelsJson {
|
|
|
246
260
|
return empty;
|
|
247
261
|
}
|
|
248
262
|
|
|
249
|
-
/**
|
|
250
|
-
* Write Pi's models.json configuration back to disk.
|
|
251
|
-
*
|
|
252
|
-
* Uses an atomic write-then-rename pattern: the new content is first written
|
|
253
|
-
* to a `.tmp` sibling file, then renamed over the target. This ensures
|
|
254
|
-
* concurrent readers never see a partially-written file.
|
|
255
|
-
*
|
|
256
|
-
* NOTE: The write-then-rename pattern provides filesystem-level atomicity
|
|
257
|
-
* but does NOT protect against concurrent read-modify-write cycles (e.g.
|
|
258
|
-
* two processes both read the file, mutate in memory, then write back).
|
|
259
|
-
* If that becomes a concern in the future, file-based locking (e.g. `proper-lockfile`)
|
|
260
|
-
* should be added around the read-modify-write sequence.
|
|
261
|
-
*
|
|
262
|
-
* @param data - The configuration object to write
|
|
263
|
-
*
|
|
264
|
-
* @example
|
|
265
|
-
* ```typescript
|
|
266
|
-
* const config = readModelsJson();
|
|
267
|
-
* config.providers["ollama"] = {
|
|
268
|
-
* baseUrl: "http://localhost:11434/v1",
|
|
269
|
-
* models: [{ id: "qwen3:0.6b" }]
|
|
270
|
-
* };
|
|
271
|
-
* writeModelsJson(config);
|
|
272
|
-
* ```
|
|
273
|
-
*/
|
|
274
|
-
export function writeModelsJson(data: PiModelsJson): void {
|
|
275
|
-
const dir = path.dirname(MODELS_JSON_PATH);
|
|
276
|
-
if (!fs.existsSync(dir)) {
|
|
277
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
278
|
-
}
|
|
279
|
-
const tmpPath = MODELS_JSON_PATH + ".tmp";
|
|
280
|
-
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
281
|
-
fs.renameSync(tmpPath, MODELS_JSON_PATH);
|
|
282
|
-
// Invalidate cache so next read picks up the written data
|
|
283
|
-
_modelsJsonCache = null;
|
|
284
|
-
_ollamaBaseUrlCache = null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// In-memory mutex for models.json write operations
|
|
288
|
-
let _modelsJsonLock: Promise<void> | null = null;
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Acquire the models.json write lock.
|
|
292
|
-
* Returns a release function that MUST be called when done.
|
|
293
|
-
* Prevents concurrent read-modify-write cycles from different extensions.
|
|
294
|
-
*/
|
|
295
|
-
export async function acquireModelsJsonLock(): Promise<{ release: () => void }> {
|
|
296
|
-
// Wait for any existing lock to be released
|
|
297
|
-
while (_modelsJsonLock) {
|
|
298
|
-
await _modelsJsonLock;
|
|
299
|
-
}
|
|
300
|
-
// Create new lock
|
|
301
|
-
let releaseLock: () => void;
|
|
302
|
-
_modelsJsonLock = new Promise<void>((resolve) => {
|
|
303
|
-
releaseLock = resolve;
|
|
304
|
-
});
|
|
305
|
-
return {
|
|
306
|
-
release: () => {
|
|
307
|
-
releaseLock!();
|
|
308
|
-
_modelsJsonLock = null;
|
|
309
|
-
},
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Safely read, modify, and write models.json under a lock.
|
|
315
|
-
* Prevents concurrent read-modify-write cycles between extensions.
|
|
316
|
-
*
|
|
317
|
-
* @param modifier - Function that receives the current data and returns modified data (or null to abort)
|
|
318
|
-
* @returns true if the write succeeded, false if aborted
|
|
319
|
-
*/
|
|
320
|
-
export async function readModifyWriteModelsJson(
|
|
321
|
-
modifier: (data: PiModelsJson) => PiModelsJson | null
|
|
322
|
-
): Promise<boolean> {
|
|
323
|
-
const { release } = await acquireModelsJsonLock();
|
|
324
|
-
try {
|
|
325
|
-
const data = readModelsJson();
|
|
326
|
-
const modified = modifier(data);
|
|
327
|
-
if (modified === null) return false;
|
|
328
|
-
writeModelsJson(modified);
|
|
329
|
-
return true;
|
|
330
|
-
} finally {
|
|
331
|
-
release();
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
263
|
// ============================================================================
|
|
336
264
|
// Ollama API Helpers
|
|
337
265
|
// ============================================================================
|
|
@@ -470,86 +398,6 @@ export async function fetchOllamaModels(baseUrl: string): Promise<OllamaModel[]>
|
|
|
470
398
|
});
|
|
471
399
|
}
|
|
472
400
|
|
|
473
|
-
/**
|
|
474
|
-
* Fetch detailed model info from Ollama's /api/show endpoint.
|
|
475
|
-
*
|
|
476
|
-
* Returns the model's context window size (`num_ctx`), along with
|
|
477
|
-
* other details like the template and system prompt. Used by
|
|
478
|
-
* ollama-sync to enrich model entries with context length metadata.
|
|
479
|
-
*
|
|
480
|
-
* @param baseUrl - The Ollama base URL (without /v1 suffix)
|
|
481
|
-
* @param modelName - The model tag name (e.g., "qwen3:0.6b")
|
|
482
|
-
* @returns Context length in tokens, or undefined if unavailable
|
|
483
|
-
*
|
|
484
|
-
* @example
|
|
485
|
-
* ```typescript
|
|
486
|
-
* const ctx = await fetchModelContextLength("http://localhost:11434", "qwen3:0.6b");
|
|
487
|
-
* console.log(ctx); // 8192
|
|
488
|
-
* ```
|
|
489
|
-
*/
|
|
490
|
-
export async function fetchModelContextLength(
|
|
491
|
-
baseUrl: string,
|
|
492
|
-
modelName: string
|
|
493
|
-
): Promise<number | undefined> {
|
|
494
|
-
return withRetry(async () => {
|
|
495
|
-
try {
|
|
496
|
-
const res = await fetch(`${baseUrl}/api/show`, {
|
|
497
|
-
method: "POST",
|
|
498
|
-
headers: { "Content-Type": "application/json" },
|
|
499
|
-
body: JSON.stringify({ name: modelName }),
|
|
500
|
-
signal: AbortSignal.timeout(30000),
|
|
501
|
-
});
|
|
502
|
-
if (!res.ok) return undefined;
|
|
503
|
-
const data = (await res.json()) as {
|
|
504
|
-
model_info?: Record<string, unknown>;
|
|
505
|
-
template?: string;
|
|
506
|
-
};
|
|
507
|
-
const modelInfo = data.model_info ?? {};
|
|
508
|
-
// Ollama uses architecture-specific keys like "qwen3.context_length"
|
|
509
|
-
for (const key of Object.keys(modelInfo)) {
|
|
510
|
-
if (key.endsWith(".context_length")) {
|
|
511
|
-
const val = modelInfo[key];
|
|
512
|
-
if (typeof val === "number") return val;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
// Fallback: generic "num_ctx" key
|
|
516
|
-
const numCtx = data?.model_info?.["num_ctx"];
|
|
517
|
-
if (typeof numCtx === "number") return numCtx;
|
|
518
|
-
} catch (err) {
|
|
519
|
-
debugLog("ollama", `failed to fetch context length for ${modelName}`, err);
|
|
520
|
-
return undefined;
|
|
521
|
-
}
|
|
522
|
-
return undefined;
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Fetch context lengths for multiple models, processing them in small batches
|
|
528
|
-
* to avoid overwhelming the connection (especially over tunnels).
|
|
529
|
-
*
|
|
530
|
-
* @param baseUrl - The Ollama base URL (without /v1 suffix)
|
|
531
|
-
* @param modelNames - Array of model tag names
|
|
532
|
-
* @param batchSize - Number of concurrent requests (default: 3)
|
|
533
|
-
* @returns Map of model name to context length (undefined if unavailable)
|
|
534
|
-
*/
|
|
535
|
-
export async function fetchContextLengthsBatched(
|
|
536
|
-
baseUrl: string,
|
|
537
|
-
modelNames: string[],
|
|
538
|
-
batchSize = 3
|
|
539
|
-
): Promise<Map<string, number | undefined>> {
|
|
540
|
-
const result = new Map<string, number | undefined>();
|
|
541
|
-
for (let i = 0; i < modelNames.length; i += batchSize) {
|
|
542
|
-
const batch = modelNames.slice(i, i + batchSize);
|
|
543
|
-
const results = await Promise.allSettled(
|
|
544
|
-
batch.map((name) => fetchModelContextLength(baseUrl, name))
|
|
545
|
-
);
|
|
546
|
-
results.forEach((r, idx) => {
|
|
547
|
-
result.set(batch[idx], r.status === "fulfilled" ? r.value : undefined);
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
return result;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
401
|
/**
|
|
554
402
|
* Check if an Ollama model name suggests reasoning capability.
|
|
555
403
|
*
|
|
@@ -728,7 +576,7 @@ function expandEnvVars(value: string): string {
|
|
|
728
576
|
*/
|
|
729
577
|
function readAuthJsonKey(providerName: string): string {
|
|
730
578
|
try {
|
|
731
|
-
const p =
|
|
579
|
+
const p = authJsonPath();
|
|
732
580
|
if (!fs.existsSync(p)) return "";
|
|
733
581
|
const entry = JSON.parse(fs.readFileSync(p, "utf-8"))[providerName];
|
|
734
582
|
return typeof entry?.key === "string" ? entry.key : "";
|
|
@@ -825,22 +673,3 @@ export function detectProvider(ctx: PiExtensionContext, modelsJson?: PiModelsJso
|
|
|
825
673
|
// Tier 3: Unknown provider
|
|
826
674
|
return { kind: "unknown", name: providerName };
|
|
827
675
|
}
|
|
828
|
-
|
|
829
|
-
// ============================================================================
|
|
830
|
-
// Local Provider Detection
|
|
831
|
-
// ============================================================================
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* Check if a provider URL indicates a local (on-machine) provider.
|
|
835
|
-
* Used by api.ts, status.ts, and diag.ts to determine whether
|
|
836
|
-
* system metrics (CPU/RAM/Swap) are meaningful.
|
|
837
|
-
*
|
|
838
|
-
* @param baseUrl - The provider's base URL (e.g. "http://localhost:11434/v1")
|
|
839
|
-
* @param providerName - Optional provider name (e.g. "ollama")
|
|
840
|
-
* @returns `true` if the provider is local, `false` otherwise
|
|
841
|
-
*/
|
|
842
|
-
export function isLocalProvider(baseUrl: string, providerName?: string): boolean {
|
|
843
|
-
if (providerName === "ollama") return true;
|
|
844
|
-
const url = baseUrl || "";
|
|
845
|
-
return url.includes("localhost") || url.includes("127.0.0.1") || url.includes("0.0.0.0");
|
|
846
|
-
}
|
|
@@ -7,19 +7,19 @@ Provides branch-aware model-managed tasks with a non-capturing overlay and full-
|
|
|
7
7
|
- `/todos`: open the current branch's full-screen task list; `Esc` closes it.
|
|
8
8
|
- Tool: `todo` with `list`, `add`, `toggle`, and `clear` actions.
|
|
9
9
|
- `shortcuts.toggleWidget`: show or hide the overlay, default `ctrl+alt+t`.
|
|
10
|
-
- `shortcuts.resetDone`: clear
|
|
10
|
+
- `shortcuts.resetDone`: clear completed todos while keeping unfinished items, default `ctrl+alt+r`.
|
|
11
11
|
|
|
12
12
|
## Extension features
|
|
13
13
|
|
|
14
14
|
- Stores todos in session tool results; switching session branches with `/tree` restores that branch's list.
|
|
15
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.
|
|
16
|
+
- Displays a top-right overlay without taking editor focus; hides below 80 columns and caps its height to the configured terminal percentage. The row count comes from the overlay renderer - pi-tui reports the dimensions it is about to draw into on every frame - so a redirected stdout or an embedded terminal of a different shape cannot make the widget size itself against the wrong screen. Until the first frame reports a height, a 24-row terminal is assumed.
|
|
17
17
|
- Shows `✓ All done!` and hides after the configured delay; `clear` hides immediately.
|
|
18
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
19
|
|
|
20
20
|
## Configuration
|
|
21
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.
|
|
22
|
+
Copy [`configs/opl-todo.json.sample`](../../configs/opl-todo.json.sample) to `~/.pi/agent/configs/opl-todo.json`, then `/reload` or restart Pi. The example below shows **chosen values, not defaults** - the defaults are in the table.
|
|
23
23
|
|
|
24
24
|
```json
|
|
25
25
|
{
|
|
@@ -33,9 +33,9 @@ Copy [`configs/opl-todo.json.sample`](../../configs/opl-todo.json.sample) to `~/
|
|
|
33
33
|
|---|---:|---|
|
|
34
34
|
| `allDoneHideMs` | `5000` | Delay before a completed overlay hides; `0` hides immediately. |
|
|
35
35
|
| `shortcuts.toggleWidget` | `ctrl+alt+t` | Toggle overlay visibility. |
|
|
36
|
-
| `shortcuts.resetDone` | `ctrl+alt+r` | Clear
|
|
36
|
+
| `shortcuts.resetDone` | `ctrl+alt+r` | Clear completed todos while keeping unfinished items. |
|
|
37
37
|
| `widget.widthPercent` | `33` | Overlay width, greater than 0 through 100. |
|
|
38
|
-
| `widget.maxHeightPercent` | `50` |
|
|
38
|
+
| `widget.maxHeightPercent` | `50` | Percentage of the **renderer-reported** terminal height used for the item cap (minus the box's own chrome, floor of 3 items). |
|
|
39
39
|
| `widget.minWidth` | `32` | Minimum overlay width; values below 20 are rejected. |
|
|
40
40
|
|
|
41
41
|
## Architecture
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
5
|
export interface TodoConfig {
|
|
6
6
|
allDoneHideMs: number;
|
|
@@ -28,7 +28,12 @@ const DEFAULT: TodoConfig = {
|
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
|
|
32
|
+
export function configPath(): string {
|
|
33
|
+
return join(getAgentDir(), "configs", "opl-todo.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function load(path = configPath()): TodoConfig {
|
|
32
37
|
try {
|
|
33
38
|
if (!existsSync(path)) return DEFAULT;
|
|
34
39
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -24,6 +24,13 @@ interface TodoDetails {
|
|
|
24
24
|
error?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
interface TodoStateSnapshot {
|
|
28
|
+
todos: Todo[];
|
|
29
|
+
nextId: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TODO_STATE_ENTRY = "opl-todo-state";
|
|
33
|
+
|
|
27
34
|
// ── Widget constants ─────────────────────────────────────────────────────────
|
|
28
35
|
|
|
29
36
|
const TodoParams = Type.Object({
|
|
@@ -32,20 +39,34 @@ const TodoParams = Type.Object({
|
|
|
32
39
|
id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
|
|
33
40
|
});
|
|
34
41
|
|
|
42
|
+
/** Rows per terminal before the overlay has reported one (first frame, or a non-TUI host). */
|
|
43
|
+
const FALLBACK_ROWS = 24;
|
|
44
|
+
|
|
45
|
+
/** How many todo lines fit in `rows` terminal rows, given the configured height budget and the
|
|
46
|
+
* box's own chrome (border, title, progress line, footer). */
|
|
47
|
+
export function widgetMaxItems(rows: number): number {
|
|
48
|
+
const usable = (rows > 0 ? rows : FALLBACK_ROWS) * CONFIG.widget.maxHeightPercent;
|
|
49
|
+
return Math.max(3, Math.floor(usable / 100) - 5);
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
/**
|
|
36
53
|
* Persistent top-right overlay. Non-capturing — never steals editor focus.
|
|
37
54
|
* Reads the shared `todos` array directly so render() always reflects live state.
|
|
38
55
|
*/
|
|
39
|
-
class TodoWidgetComponent {
|
|
56
|
+
export class TodoWidgetComponent {
|
|
40
57
|
constructor(
|
|
41
58
|
private readonly todos: Todo[],
|
|
42
59
|
private readonly theme: Theme,
|
|
60
|
+
// The height comes from the overlay renderer, which reports the terminal it is drawing
|
|
61
|
+
// into on every frame. The harness terminal size is not that: with stdout redirected, or
|
|
62
|
+
// an embedded terminal of a different shape, it describes some other screen.
|
|
63
|
+
private readonly terminalRows: () => number = () => FALLBACK_ROWS,
|
|
43
64
|
) {}
|
|
44
65
|
|
|
45
66
|
render(width: number): string[] {
|
|
46
67
|
const th = this.theme;
|
|
47
68
|
const t = this.todos;
|
|
48
|
-
const maxItems =
|
|
69
|
+
const maxItems = widgetMaxItems(this.terminalRows());
|
|
49
70
|
const W = Math.max(width, CONFIG.widget.minWidth);
|
|
50
71
|
|
|
51
72
|
const b = (s: string) => th.fg("border", s);
|
|
@@ -168,6 +189,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
168
189
|
// ── Widget handles ─────────────────────────────────────────────────────
|
|
169
190
|
let overlayHandle: OverlayHandle | undefined;
|
|
170
191
|
let tuiRef: TUI | undefined;
|
|
192
|
+
// Terminal height as the overlay renderer last reported it. 0 until the first frame.
|
|
193
|
+
let overlayRows = 0;
|
|
171
194
|
let autoHideTimer: ReturnType<typeof setTimeout> | undefined;
|
|
172
195
|
|
|
173
196
|
function showWidget(): void {
|
|
@@ -194,6 +217,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
194
217
|
nextId = 1;
|
|
195
218
|
|
|
196
219
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
220
|
+
if (entry.type === "custom" && entry.customType === TODO_STATE_ENTRY) {
|
|
221
|
+
const state = entry.data as TodoStateSnapshot | undefined;
|
|
222
|
+
if (state && Array.isArray(state.todos) && typeof state.nextId === "number") {
|
|
223
|
+
todos.length = 0;
|
|
224
|
+
todos.push(...state.todos);
|
|
225
|
+
nextId = state.nextId;
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
197
229
|
if (entry.type !== "message") continue;
|
|
198
230
|
const msg = entry.message;
|
|
199
231
|
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
|
|
@@ -226,7 +258,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
226
258
|
void ctx.ui.custom<void>(
|
|
227
259
|
(tui, theme, _kb, _done) => {
|
|
228
260
|
tuiRef = tui;
|
|
229
|
-
return new TodoWidgetComponent(todos, theme);
|
|
261
|
+
return new TodoWidgetComponent(todos, theme, () => overlayRows);
|
|
230
262
|
},
|
|
231
263
|
{
|
|
232
264
|
overlay: true,
|
|
@@ -236,7 +268,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
268
|
offsetY: 0,
|
|
237
269
|
width: `${CONFIG.widget.widthPercent}%`,
|
|
238
270
|
nonCapturing: true,
|
|
239
|
-
|
|
271
|
+
// pi-tui calls this every frame with the dimensions it is about to draw into, so the
|
|
272
|
+
// widget sizes itself against the screen it is actually on.
|
|
273
|
+
visible: (w, h) => {
|
|
274
|
+
overlayRows = h;
|
|
275
|
+
return w >= 80;
|
|
276
|
+
},
|
|
240
277
|
},
|
|
241
278
|
onHandle: (h) => {
|
|
242
279
|
overlayHandle = h;
|
|
@@ -259,12 +296,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
259
296
|
});
|
|
260
297
|
|
|
261
298
|
pi.registerShortcut(CONFIG.shortcuts.resetDone as KeyId, {
|
|
262
|
-
description: "Clear completed todos
|
|
299
|
+
description: "Clear completed todos",
|
|
263
300
|
handler: (_ctx) => {
|
|
264
|
-
|
|
265
|
-
todos.length
|
|
266
|
-
|
|
267
|
-
|
|
301
|
+
const remaining = todos.filter((todo) => !todo.done);
|
|
302
|
+
if (remaining.length === todos.length) return;
|
|
303
|
+
todos.splice(0, todos.length, ...remaining);
|
|
304
|
+
if (todos.length === 0) nextId = 1;
|
|
305
|
+
clearTimeout(autoHideTimer);
|
|
306
|
+
pi.appendEntry(TODO_STATE_ENTRY, { todos: [...todos], nextId } satisfies TodoStateSnapshot);
|
|
307
|
+
if (todos.length === 0) hideWidget();
|
|
308
|
+
else showWidget();
|
|
268
309
|
},
|
|
269
310
|
});
|
|
270
311
|
|
|
@@ -13,9 +13,10 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
|
|
|
13
13
|
## Extension features
|
|
14
14
|
|
|
15
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,
|
|
17
|
-
- Restricts `fetch_content` to http/https and
|
|
18
|
-
- Caps initial tool output at a configurable length (default 30,000 characters)
|
|
16
|
+
- Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, normalizes PDF response buffers to plain `Uint8Array` input for text extraction, and returns plain text, Markdown, and JSON directly.
|
|
17
|
+
- Restricts `fetch_content` to http/https and applies its host policy to **resolved addresses**: a URL is rejected when any DNS answer for its hostname falls in blocked space, not merely when the hostname is written as an internal literal. Private/link-local/reserved answers are blocked by default (`allowPrivateNetwork` to opt in), loopback answers are refused unless `allowLoopback: true` opts in, and cloud-metadata and unspecified addresses (`0.0.0.0/8`, `::`) are always blocked. Every redirect hop is re-validated and re-resolved, so a public first hop cannot bounce into internal space. Caps each response at 10 MB and applies a 30s timeout; the DNS round-trip is bounded by the same signal, and concurrent lookups for one host share a single query. Gemini keys are sent via the `x-goog-api-key` header rather than the query string. The addresses that pass are the ones the socket dials, so a short-TTL record cannot answer one address for the check and another for the connection.
|
|
18
|
+
- Caps initial tool output at a configurable length (default 30,000 characters) and keeps results retrievable for one hour (expiry is enforced on read, not just on write) or until the session ends.
|
|
19
|
+
- Keeps the full body **out of the session transcript**: each result spills to `<agent dir>/web-access-cache/<responseId>.json` (the directory pi itself uses, so a relocated `PI_CODING_AGENT_DIR` moves it) and the session entry stores a bounded preview (512 bytes per answer or URL content, marked `truncated`) plus the file path. Reads rehydrate from disk; if the spill file is gone or expired, the preview is returned with a note to re-fetch rather than a silent stub. The cache is pruned by age, then oldest-first past a 32 MB ceiling.
|
|
19
20
|
- Pages `get_search_content` results (default 30,000 characters per page) with `offset` continuation, so a model never loads the whole stored body in one call.
|
|
20
21
|
- Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
|
|
21
22
|
|
|
@@ -43,7 +44,10 @@ Optional top-level caps control how much content reaches the model:
|
|
|
43
44
|
| `maxRetrievalChars` | `30000` | Cap on one `get_search_content` page. Pass `offset` to continue. |
|
|
44
45
|
| `maxSearchQueries` | `10` | Cap on how many queries one `web_search` call runs. Excess queries are skipped and reported. |
|
|
45
46
|
| `maxFetchUrls` | `20` | Cap on how many URLs one `fetch_content` call fetches. Excess URLs are skipped and reported. |
|
|
46
|
-
| `
|
|
47
|
+
| `timeoutMs` | `30000` | Deadline for one provider search or URL fetch, composed with the caller's abort signal. A slow provider fails as its own error instead of hanging the tool. |
|
|
48
|
+
| `maxResponseBytes` | `10485760` | Independent ceilings on received and decoded response bytes. Exceeding either stops reading and closes the socket and decoder before output truncation. |
|
|
49
|
+
| `allowPrivateNetwork` | `false` | Allow `fetch_content` to reach private/link-local ranges, literal or resolved (cloud metadata is always blocked). Provider API endpoints (e.g. `ddgs.apiUrl`, `searxng.instanceUrl`) are excluded from this guard. |
|
|
50
|
+
| `allowLoopback` | `false` | Allow `fetch_content` to reach loopback — `localhost`, `127.0.0.0/8`, `::1`, literal or resolved. Set `true` to fetch from a local dev server; metadata and private ranges are unaffected by this key. |
|
|
47
51
|
|
|
48
52
|
Install extraction dependencies before use:
|
|
49
53
|
|
|
@@ -54,6 +58,28 @@ npm install
|
|
|
54
58
|
|
|
55
59
|
`@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.
|
|
56
60
|
|
|
61
|
+
### Network policy
|
|
62
|
+
|
|
63
|
+
`fetch_content` classifies the address it is about to contact, not the text in the URL:
|
|
64
|
+
|
|
65
|
+
1. Scheme, userinfo and literal-host checks (`assertHttpUrl`).
|
|
66
|
+
2. For a non-literal hostname, `node:dns` `lookup(host, { all: true, verbatim: true })`, then the same classifier over **every** answer. One internal answer among many public ones rejects.
|
|
67
|
+
3. The addresses that passed are handed to the transport, which dials them through a pinned `lookup` (`http.ts`). Nothing in the connection path resolves the name again.
|
|
68
|
+
4. Each redirect hop re-enters at step 2 with the new hostname — re-resolved and re-pinned, with no reuse across hops and no cache between calls.
|
|
69
|
+
5. `Host` and TLS `servername` keep the hostname, so virtual hosting and certificate validation behave as they would unpinned. Sockets are never pooled, so a connection made under one hop's answer cannot carry a later request.
|
|
70
|
+
|
|
71
|
+
The transport requests `identity` encoding but decodes `gzip`, `deflate` and `br` when a server compresses anyway. `maxResponseBytes` (default 10485760) independently caps received and decoded bytes; malformed, unsupported or chained encodings return an error. Redirects (301/302/303/307/308 with `Location`) return headers immediately and discard their bodies before the next hop is validated. The overall `timeoutMs` budget spans all hops; cancellation, inactivity and byte-limit failures close the request, response and decoder.
|
|
72
|
+
|
|
73
|
+
Compressed and fully expanded IPv6 forms share the same policy, including DNS answers. NAT64 (`64:ff9b::/96`), 6to4 (`2002::/16`), mapped and IPv4-compatible addresses are classified by their embedded IPv4. The entire local-use translation prefix `64:ff9b:1::/48` is always blocked, regardless of `allowPrivateNetwork` or `allowLoopback`; this also blocks translations of public IPv4 addresses within that prefix.
|
|
74
|
+
|
|
75
|
+
A resolver failure rejects the fetch (fail closed): unlike a browser's subresources, this URL was named by the model, so a host that does not resolve is an error to report rather than a request to wave through. The lookup honors the caller's abort signal and the 30s fetch budget.
|
|
76
|
+
|
|
77
|
+
Residuals, deliberately left open:
|
|
78
|
+
|
|
79
|
+
- **Operator endpoints are not pinned.** Provider API endpoints (`ddgs.apiUrl`, `searxng.instanceUrl`) are chosen by you, not by the model, so they stay reachable on private or loopback hosts and go through the ordinary client.
|
|
80
|
+
- **A response body can describe internal services.** The guard decides where the tool connects, not what the text says.
|
|
81
|
+
- **`opl-browser` cannot pin.** Chromium resolves names internally and no Playwright API exposes a per-connection resolver; see its README. This pin covers `fetch_content`, not page traffic.
|
|
82
|
+
|
|
57
83
|
## Architecture
|
|
58
84
|
|
|
59
|
-
`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.
|
|
85
|
+
`index.ts` registers the tools and session storage. Provider adapters live under `providers/`; `search.ts` dispatches search, `http.ts` owns the pinned transport (one request per hop, body cap enforced while reading), `extract.ts` converts readable content, `pdf.ts` handles PDFs, and stored response data backs `get_search_content`. `utils.ts` exports `resolveSafeHostUrl`, the only place a policy decision produces the address set a socket may use; `assertSafeHttpUrl` is the string-only wrapper for callers that never connect. Smoke tests bundle with these runtime dependencies externalized; they do not exercise credentials, network, extraction, or PDF behavior.
|
|
@@ -26,15 +26,28 @@ export interface WebAccessConfig {
|
|
|
26
26
|
maxSearchQueries?: number;
|
|
27
27
|
/** Cap on the number of URLs a single fetch_content call may fetch. */
|
|
28
28
|
maxFetchUrls?: number;
|
|
29
|
-
/** Allow fetch_content to reach private/link-local ranges (
|
|
30
|
-
*
|
|
29
|
+
/** Allow fetch_content to reach private/link-local ranges (default false). Cloud
|
|
30
|
+
* metadata is always blocked. */
|
|
31
31
|
allowPrivateNetwork?: boolean;
|
|
32
|
+
/** Allow fetch_content to reach loopback hosts — localhost, 127.0.0.0/8, ::1
|
|
33
|
+
* (default false: opt in). Cloud metadata is unaffected. */
|
|
34
|
+
allowLoopback?: boolean;
|
|
35
|
+
/** Deadline applied to one provider search request or one fetched URL (ms,
|
|
36
|
+
* default 30000). The agent's own abort signal is combined with it, so the
|
|
37
|
+
* shorter of the two wins. */
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
/** Ceiling on one fetched response body (bytes, default 10485760). The transport stops
|
|
40
|
+
* reading and drops the socket past it, so an oversized page cannot cost memory before
|
|
41
|
+
* content truncation gets a say. */
|
|
42
|
+
maxResponseBytes?: number;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
export const DEFAULT_MAX_CONTENT_CHARS = 30_000;
|
|
35
46
|
export const DEFAULT_MAX_RETRIEVAL_CHARS = 30_000;
|
|
36
47
|
export const DEFAULT_MAX_SEARCH_QUERIES = 10;
|
|
37
48
|
export const DEFAULT_MAX_FETCH_URLS = 20;
|
|
49
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
50
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
38
51
|
|
|
39
52
|
/** Coerce an unknown value into a positive integer, else the fallback. */
|
|
40
53
|
function positiveInt(value: unknown, fallback: number): number {
|
|
@@ -55,7 +68,20 @@ export function resolveCaps(
|
|
|
55
68
|
};
|
|
56
69
|
}
|
|
57
70
|
|
|
58
|
-
|
|
71
|
+
/** Per-request deadline for provider calls and URL fetches (ms). */
|
|
72
|
+
export function resolveTimeoutMs(cfg: Pick<WebAccessConfig, "timeoutMs">): number {
|
|
73
|
+
return positiveInt(cfg.timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Per-response body ceiling for URL fetches (bytes). */
|
|
77
|
+
export function resolveMaxResponseBytes(cfg: Pick<WebAccessConfig, "maxResponseBytes">): number {
|
|
78
|
+
return positiveInt(cfg.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolved per call so a custom `PI_CODING_AGENT_DIR` (pi's own agent dir) is honoured. */
|
|
82
|
+
export function configPath(): string {
|
|
83
|
+
return join(getAgentDir(), "configs", "opl-webaccess.json");
|
|
84
|
+
}
|
|
59
85
|
|
|
60
86
|
const DEFAULT_CONFIG: WebAccessConfig = {
|
|
61
87
|
provider: "gemini",
|
|
@@ -68,13 +94,13 @@ const DEFAULT_CONFIG: WebAccessConfig = {
|
|
|
68
94
|
},
|
|
69
95
|
};
|
|
70
96
|
|
|
71
|
-
export function loadConfig(): WebAccessConfig {
|
|
72
|
-
if (!existsSync(
|
|
97
|
+
export function loadConfig(file = configPath()): WebAccessConfig {
|
|
98
|
+
if (!existsSync(file)) return DEFAULT_CONFIG;
|
|
73
99
|
let parsed: unknown;
|
|
74
100
|
try {
|
|
75
|
-
parsed = JSON.parse(readFileSync(
|
|
101
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
76
102
|
} catch {
|
|
77
|
-
console.error(`[opl-webaccess] failed to parse ${
|
|
103
|
+
console.error(`[opl-webaccess] failed to parse ${file}, using defaults`);
|
|
78
104
|
return DEFAULT_CONFIG;
|
|
79
105
|
}
|
|
80
106
|
return normalizeConfig(parsed);
|
|
@@ -106,6 +132,9 @@ function normalizeConfig(parsed: unknown): WebAccessConfig {
|
|
|
106
132
|
maxSearchQueries: record.maxSearchQueries as number | undefined,
|
|
107
133
|
maxFetchUrls: record.maxFetchUrls as number | undefined,
|
|
108
134
|
allowPrivateNetwork: record.allowPrivateNetwork === true,
|
|
135
|
+
allowLoopback: record.allowLoopback === true,
|
|
136
|
+
timeoutMs: record.timeoutMs as number | undefined,
|
|
137
|
+
maxResponseBytes: record.maxResponseBytes as number | undefined,
|
|
109
138
|
};
|
|
110
139
|
}
|
|
111
140
|
|