119 Commits
Author SHA1 Message Date
EislM0203 02b2f038eb fix release 2026-06-29 21:50:45 +02:00
EislM0203andmarkus.eisl 9d05a67512 custom-providers: live loaded-model status + thinking support for llamacpp
- Extend ProviderStatus with loadedModels[] populated from /running;
  llamacpp panel now shows active model name instead of just "N models"
- Return DiscoverResult from discoverProvider (models + loadedModels)
- All llamacpp models get reasoning:true + compat.thinkingFormat:"qwen-chat-template"
  so Ctrl+T / Shift+Tab work for both Qwen3 and Gemma4 (verified live)
2026-06-29 16:28:30 +02:00
EislM0203 336d26bfcd fix: correctness and reliability bugs across sync and k8s extensions
- s3: guard resp.Body against undefined before calling transformToByteArray
- s3: throw instead of returning 0 when both pi-mtime metadata and
  LastModified are absent (non-conformant S3 response)
- s3: parallelize HeadObjectCommand calls per list() page with Promise.all
  instead of serial awaits, reducing N+1 round-trips to one per page
- sync: emit skip(local-only-in-pull) for local-only files in pull mode so
  they appear in the report instead of being silently dropped
- manifest: convert readManifest/writeManifest to async fs.promises to
  avoid blocking the Node.js event loop on slow/network filesystems
- panel: track lastError separately from lastReport so a sync exception
  shows a persistent error line instead of leaving the previous report
- k8s/diag: set identity to "(auth whoami unavailable)" when the can-i
  --list fallback succeeds, instead of leaving it as "(unknown identity)"
- k8s/index: warn when a configured helper model is not found in the
  registry rather than silently falling back to the active model
2026-06-29 13:41:03 +00:00
markus.eisl 3363886640 panels: match letter shortcuts via matchesKey for Kitty keyboard protocol 2026-06-29 12:40:15 +02:00
EislM0203andClaude Opus 4.7 0ef000e623 sync ext: drop auto pull/push, make /sync pull always override local
- Remove session_start (pull) and session_shutdown (prune+push) handlers
  from extensions/sync/index.ts; sync is now driven only by the /sync
  panel and subcommands.
- /sync pull now downloads every file present in S3 regardless of local
  mtime or manifest state. The first-pull manifest-absence gate is gone;
  the download reason is renamed first-pull -> force-pull.
- Update sync.test.ts: the former LWW test now asserts pull overrides
  local even when the manifest records the key.
- README: drop the "Lifecycle hooks" section and rewrite the conflict
  model to reflect "remote always wins for pull".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 00:31:08 +02:00
EislM0203 9fb63148fe k8s ext: helper-model summarization, missing-kubectl + TLS env handling
- Summarize k8s_get/describe/logs/events through a helper model when
  output exceeds a configurable threshold (default 4000 chars). Optional
  `prompt` biases the summary; `raw=true` bypasses it. Config + helper
  picker live in the /k8s panel. Naming mirrors websearch
  (helperModel/helperTimeoutMs/helperMaxTokens/helperUsed).
- PI_K8S_INSECURE_SKIP_TLS now accepts 1/true/yes/on so SOPS can encrypt
  the value as a string.
- classifyKubectlResult recognises ENOENT (empty stdout+stderr, code≠0)
  as kind=missing-binary; diag short-circuits with a clear panel state
  and session_start warns once.
- Sync: pull on a fresh machine no longer skips files where the local
  default has mtime>remote — first-pull (no manifest entry) forces a
  download. Fixes custom-providers.json reverting on every sandbox.
- websearch typecheck fix: replace DOM Document reference with `as never`.
2026-06-01 15:25:51 +02:00
EislM0203andClaude Opus 4.7 4f387a278b add S3-backed config + memory sync extension
New extensions/sync/ that bidirectionally syncs ~/.pi/agent/ configs +
memories with an S3 bucket so fresh OpenShell sandboxes (or new laptops)
boot with existing pi state.

- Allowlist: settings.json, k8s.json, websearch.json, custom-providers.json,
  hosts.json, local-models.json, memory/**. Excludes auth.json
  (provider API keys; injected per-sandbox).
- Per-file last-write-wins by real mtime carried in x-amz-meta-pi-mtime;
  1s clock-jitter tolerance.
- Per-machine ~/.pi/agent/.sync-manifest.json records what was last seen
  on S3 so auto-prune can't wipe a concurrent laptop's data.
- Manual /sync command opens a TUI panel; subcommands /sync pull, push,
  prune, status work without the panel.
- session_start auto-pulls (5s soft timeout, non-blocking).
- session_shutdown auto-prunes then auto-pushes (10s hard timeout).
- Config via PI_AWS_* env vars only (chicken/egg: nothing to bootstrap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:01:58 +02:00
EislM0203andClaude Opus 4.7 9c64807ac3 plan: S3-backed config + memory sync extension
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:43:13 +02:00
EislM0203andClaude Opus 4.7 4ad5b643f8 spec: S3-backed config + memory sync extension
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:36:19 +02:00
EislM0203andClaude Opus 4.7 2fa12cd8ea k8s ext: allow setting the SA token via the /k8s panel
The env-only constraint on KUBE_TOKEN only mattered for OpenShell's
placeholder rewriting; since the extension runs outside OpenShell, the
token is just a plain credential the user supplies. Mirror the cluster
URL flow: press `t` in /k8s to set the token, persisted to
~/.pi/agent/k8s.json (0600).

- config.ts: K8sConfig gains optional `token`
- resolveK8sEnv: env first, then config file, for both URL and token
- panel: `t` affordance + token-source status line; prompts never
  prefill/echo the secret (ctx.ui.input has no masking)
- fix: promptApiServer now merges instead of overwriting (was dropping
  a saved token); both prompts read-merge-write
- README documents the plaintext-on-disk tradeoff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:49:38 +02:00
EislM0203andClaude Opus 4.7 415150c6a3 add k8s read-only troubleshooting extension
A new extensions/k8s/ extension giving the agent read-only Kubernetes
tools (k8s_get, k8s_describe, k8s_logs, k8s_events, k8s_top, k8s_explain,
k8s_context). Credentials: KUBE_TOKEN from the env, cluster URL from
KUBE_APISERVER env or ~/.pi/agent/k8s.json (editable via the /k8s panel).

All kubectl invocations go through one exec.ts chokepoint that prepends
--server/--token; the token never reaches any render path. A /k8s panel
runs reachability + can-i probes and lets the user set the cluster URL
(press e). A bundled "k8s" mode restricts the toolset to read + grep +
k8s_* for focused troubleshooting. README ships the SA + RBAC manifest.

Container changes (Dockerfile):
  - install kubectl
  - bake customizations into /app (OpenShell Landlock allowlists /app,
    not /opt) and chmod world-readable so the remapped sandbox uid can
    read them
  - openshell-policy.yaml: baseline filesystem/landlock/process policy

Also deletes the unused status-line.ts demo extension.

Known limitation: does NOT work inside an OpenShell sandbox yet —
OpenShell hardcodes the k8s control-plane ports (6443 et al.) as
universally blocked in its SSRF engine with no override. Documented in
extensions/k8s/README.md ("Why not OpenShell yet"). The extension works
when pi runs directly on a host with cluster access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:34:44 +02:00
EislM0203andClaude Opus 4.7 7c650dd13e plan: k8s read-only extension implementation
10-task plan for extensions/k8s/: exec chokepoint + 7 typed tools +
diag/panel + index wiring + bundled k8s mode + README. TDD per task
with vitest unit tests on all pure helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:33:52 +02:00
EislM0203andClaude Opus 4.7 e54830099f spec: drop streaming output from k8s ext (pi.exec is one-shot)
pi.exec returns one captured result; ssh-hosts uses its own streamer.
For the k8s extension, single-shot output with tail caps is fine and
simpler. Streaming can be added later if log latency proves to matter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:27:10 +02:00
EislM0203andClaude Opus 4.7 513cafed8a docs: spec for k8s read-only extension
Brainstormed design for an extensions/k8s/ extension that gives the
agent read-only kubectl tools, credentialed via an NVIDIA OpenShell
provider injecting KUBE_APISERVER + KUBE_TOKEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:34:52 +02:00
EislM0203andClaude Opus 4.7 854b4c2319 add todos extension: per-session ledger as a drift anchor
A single todo_write tool (add/complete/update/remove/clear) writes
full-state snapshots to the session via appendEntry. The current list
is injected into the system prompt each turn and surfaced as a
done/total counter in the status line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:58:39 +02:00
EislM0203 b8af9890e8 fix Dockerfile 2026-05-20 08:01:40 +02:00
EislM0203andClaude Opus 4.7 13b51ed7ea custom-providers panel: edit truly preserves existing values
The pi ExtensionUI.input(title, placeholder?, opts?) second arg is a
placeholder hint, not an initial value — there is no API for an
initial value. So on edit, hitting Enter on an "empty" field was
actually submitting an empty string. The previous panel pattern
silently turned that into the empty string, making edit behave
identically to add. Fix:

- Show current value in the title: "X (current: Y, blank to keep)".
- Treat empty trimmed input as "keep current" for every field.
- Esc (input returns undefined) still cancels the whole flow.
- Removed the now-unused promptField helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:24:48 +02:00
EislM0203andClaude Opus 4.7 d22a7807d2 custom-providers panel: kind-aware URL prompt + current-kind pre-select
Two UX fixes after live testing:

- The select dialog has no native pre-fill, so on edit the current
  kind was invisible. Reorder the options so the existing kind is
  first (Enter accepts it) and append "(current: <kind>)" to the
  prompt label.
- Asking ollama users for an "OpenAI-compatible base URL" is the
  wrong mental model. Once kind is chosen, prompt for the bare
  server URL ("Ollama server URL (e.g. http://localhost:11434)" or
  "llama-swap server URL (e.g. http://localhost:9000)") and append
  /v1 on save. Edit prefill strips /v1 + trailing slashes so users
  see the bare host. Storage shape (baseUrl with /v1) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:17:18 +02:00
EislM0203andClaude Opus 4.7 7da370744e custom-providers panel: kind select + [kind] column
Replace the transitional kind placeholder with a proper ctx.ui.select
step in the add/edit flow. List row gains a [kind] column between
name and baseUrl. Required fields (name, kind, baseUrl) all cancel
with a consistent warning notify; apiKey and defaultCtx remain
optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:02:15 +02:00
EislM0203andClaude Opus 4.7 6882d80ba2 custom-providers: derive nativeUrl from already-trimmed baseUrl
Cosmetic consistency fix surfaced in code review — both fetch URLs
now compose from the trimmed `baseUrl` local, making the trim/derive
pipeline read as a single chain. Behavior unchanged; the previous
regex /\/v1\/?\$/ matched both with and without trailing slash, but
the asymmetry was easy to misread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:00:58 +02:00
EislM0203andClaude Opus 4.7 8932846449 custom-providers: kind discriminator, drop nativeUrl
Add required kind: "ollama" | "llamacpp" to CustomProvider; remove
nativeUrl. Discovery now dispatches on kind: ollama probes /api/show
per model, llamacpp probes /running once. nativeUrl is derived inline
from baseUrl (strip trailing /v1). loadConfig validates kind and
all other field types; a missing or unrecognized kind makes the file
"corrupt" and the seed file is used in memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:58:00 +02:00
EislM0203andClaude Opus 4.7 ec8ed9cc2a Rename local-models extension to custom-providers
Mechanical rename: directory, type (LocalProvider → CustomProvider),
slash command (/local-models → /custom-providers), config path
(local-models.json → custom-providers.json), panel title, and stale
comment references in subagent extensions. No behavioral change —
kind discriminator and nativeUrl removal land in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:50:54 +02:00
EislM0203andClaude Opus 4.7 b1be8bd224 Add implementation plan for custom-providers rename + kind unification
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:40:28 +02:00
EislM0203andClaude Opus 4.7 3ef3300fee extensions: replace the built-in startup header with the π mascot
The default header was a version line + keybinding crib + "press ctrl+o
for more" reminder, repeated every session start. Swap it for the block-
art π mascot example that ships in @earendil-works/pi-coding-agent's
examples/extensions/. Subtitle trimmed from the example's self-
deprecating tagline to a plain "pi vX.Y.Z".

Hits ctx.ui.setHeader() on session_start. Keybinding hints still
reachable via ctrl+o, and /builtin-header restores the old display if
needed.

The [Prompts] / [Extensions] block printed after the header is a
separate "loaded resources" view controlled by the quietStartup setting
in ~/.pi/agent/settings.json — toggle that to silence it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:29:31 +02:00
EislM0203andClaude Opus 4.7 f5cf1c4598 websearch: collapse advanced config knobs behind an 'a' toggle
The /websearch panel had 11 rows after v1.1 wired up the new tunables —
too dense for the common case. Default view now shows only the 5
load-bearing fields (URL, max results, char limit, sandbox, helper);
press 'a' to reveal the advanced section (fetch/helper timeouts,
helper max tokens, user-agent, size cap multiplier, max redirects).

Advanced shortcuts (f/l/k/u/z/r) only trigger when the section is
visible — pressing them in basic view is a no-op rather than a silent
config edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:26:06 +02:00
EislM0203andClaude Opus 4.7 6fb4be1ad0 extensions: remove auto-commit-on-exit
It auto-commits every session with "[pi] <first line of last assistant
message>" — those commits land in the middle of intentional work and
need to be soft-reset + reauthored constantly. Easier to remove than
to retrain around.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:25:03 +02:00
EislM0203andClaude Opus 4.7 08fd0ccc90 websearch: trim fetch_url renderResult — show length, not a body snippet
The previous renderResult sliced the first 200 chars of the markdown
output and put it on the result line. For a helper-summary fetch that
meant the first sentence of an LLM's answer; for raw fetches it was
opaque markdown ("# Configuration\n\nPaperless provides..."). Either
way the snippet repeated content the user can already see in the full
result panel and added no signal.

Replace with a length-on-success / kind-on-failure summary using the
structured details we already bubble up:
  ✓ 14300 chars
  ✓ 80012 chars (truncated)
  ✗ http 403
  ✗ cross-domain-redirect
  ✗ too-large

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:23:59 +02:00
EislM0203andClaude Opus 4.7 ad07c25711 websearch: drop dead cross-host redirect bookkeeping
A2 (commit 4978123) made it impossible for a successful fetch to end
on a different host than the request: followRedirects refuses any
cross-host hop, so the success path always returns a finalUrl on the
original host. The crossHostRedirect bookkeeping kept around after
that change was always false:

- classifyRedirect() / its prefix string / FetchSuccess.crossHostRedirect
  / successDetails.crossHostRedirect — all dead.

Removed across fetch.ts, index.ts, and the tests. ~25 lines net. If
we ever want to allow some cross-host redirects (trusted-host
allowlist), the field can come back then.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:23:23 +02:00
EislM0203andClaude Opus 4.7 03a8b23833 websearch: v1.1 surface — opts-object fetchUrl, structured search outcome, panel knobs
Three public-surface cleanups now that the cache is gone.

fetchUrl(url, opts): charLimit is now part of FetchOpts instead of a
positional sibling. Everything tunable lives in one place. All call
sites updated.

SearchOutcome now carries `kind` and optional `status` on failure,
matching FetchOutcome's shape. SearchErrorKind = "not-configured" |
"network" | "http" | "body". The web_search tool surfaces both in
result.details so the calling agent can branch without parsing
strings (same pattern A5 introduced for fetch).

panel.ts exposes every config knob the v1.0 set added: fetch /
helper timeouts, helper max tokens, user agent, size cap multiplier,
max redirects. New single-letter shortcuts (f/l/k/u/z/r). The
prompt-for-positive-int pattern is collapsed into one helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:18:58 +02:00
EislM0203andClaude Opus 4.7 e3af1f275a websearch: remove the fetch-result cache
The in-memory page cache was solving the wrong problem for pi's usage.

What it cost:
- ~120 lines across fetch.ts, config.ts, and the test files
- The "negative caching" footgun: a transient 503 or DNS blip poisoned
  the cache for 15 minutes with no retry path
- Recurring maintenance to keep the cache key in sync with new opts
  (we just did this round in df2b1cd)

What it gave back, in practice:
- A speed win on the rare case where an agent fetches the exact same
  URL twice in a 15-min window. Typical agent loops fetch different
  URLs per iteration, so this hit rate is near zero for interactive use.

Removed: CacheEntry / cache Map / cacheKey / cacheGet / cacheSet /
__clearCache, the cacheTtlMs config field, the clearFetchCache call in
session_start, and 6 cache-specific tests. fetchUrl is now a thin
wrapper around the renamed performFetch.

103 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:16:10 +02:00
EislM0203andClaude Opus 4.7 df2b1cdafa websearch: cache correctness — full opts in fetch key, loadConfig memoized
Two cache holes that mattered.

fetch.ts cache key was (url, charLimit) only. Other options that affect
the cached outcome — sizeCapMultiplier, maxRedirects, userAgent —
weren't keyed. Fetching the same URL with a smaller sizeCapMultiplier
after a successful larger-cap fetch returned the prior outcome and
silently skipped the new size check. Now the key includes every option
that changes the result content (timeoutMs and cacheTtlMs don't — same
outcome, just different timing).

loadConfig() was reading websearch.json from disk on every tool call —
two reads per fetch_url, one per web_search. Memoize per filePath, with
invalidation on save (saveConfig updates the cache) and on session_start
(picks up external edits). Tests get __invalidateConfigCache in
beforeEach/afterEach so they don't leak state across cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:10:11 +02:00
EislM0203andClaude Opus 4.7 4710a6176f websearch: pure-helper refactor pass — no behavior change
Five internal cleanups that pay off in readability and remove a latent
ordering bug, none of which change observable output:

- dom-shim.d.ts: small local DOM type interface for linkedom (which
  ships near-empty types). Replaces ~11 `any` / `as any` sites across
  html-extract and site-extractors with typed parameters and accesses.

- html-extract: snapshot the body's outerHTML before Readability runs,
  so the fallback path reparses a clean tree instead of mutating the
  document Readability already operated on. stripChrome is now only
  ever called on throwaway docs. Pure helpers bodyToMarkdown() and
  runReadability() replace inline mutation.

- fetch: combine buildRedirectPrefix() + isCrossHost() into one
  classifyRedirect() that parses the URL pair once and returns both
  fields. Removes a duplicate try/catch.

- fetch: split fetchUrlNoCache from ~90 lines into named helpers
  (isHtml, isTextual, truncateAtCharLimit, checkDeclaredSize,
  readBodyAsMarkdown). Same control flow, the top-level function is
  now scannable.

- index: rename wrapWarning → helperUnavailableResult with a real
  SuccessDetails type for its parameter; the prior signature was an
  any-typed assembly that grew accidentally during A5.

All 105 tests pass; live URLs (github PR, wikipedia, reddit) extract
the same byte counts as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:04:12 +02:00
EislM0203andClaude Opus 4.7 1b3d2b0a7c websearch: fix size-cap regression, clamp web_search count to maxResults
Two real bugs surfaced in actual use.

Size cap rejected normal pages. The streaming size guard was checking
total bytes against charLimit × multiplier, which for a user with the
old fetchCharLimit=10000 (the default before the v1 bump) gave a 100 KB
ceiling. Almost every modern web page is 200–700 KB of HTML, so every
fetch failed as too-large after we had already extracted the content.
The original intent — don't download multi-MB binary dumps — is now
served by:

- Streaming reader truncates instead of rejecting. We keep what we've
  read up to the cap, mark truncated=true, and continue to extraction.
- Content-Length pre-check still refuses obvious oversize downloads.
- An absolute 2 MB floor on the byte cap, independent of charLimit, so
  small charLimit values don't cause spurious rejections.

web_search count not clamped. The model could pass count=10 even when
the user had configured maxResults=8. Now Math.min(requested,
config.maxResults) — the config is the ceiling.

Live-tested all six URLs from the user's failing transcript at the
broken charLimit=10000 — all return content now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:41:16 +02:00
EislM0203andClaude Opus 4.7 b81dad0467 websearch: tokenizer-aware helper budget, slim comments
B3+B4: replace contextWindow * 0.5 * 4 magic with explicit math —
input_budget_tokens = contextWindow - responseMaxTokens
                                    - SYSTEM_PROMPT_TOKEN_RESERVE,
clamped at MIN_INPUT_TOKEN_BUDGET, converted via CHARS_PER_TOKEN. Now
auto-scales when the helper switches between a 32k qwen and a 200k
Sonnet. Pi-ai exposes no per-model tokenizer; 3.5 chars/token errs short.

Also trim verbose comments added during the prod-ready pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:33:11 +02:00
EislM0203andClaude Opus 4.7 63c3a65e15 websearch: move site-specific extractors into a declarative table
Imperative extractGithubArticle becomes a data table in
site-extractors.ts. Adding a new site is now a data change (one entry,
host regex + selectors) instead of a code change. If a listed site
renames a selector, the extractor returns null and we fall through to
Readability + the density-based fallback — no broken output, just
slightly worse extraction until the table is updated.

The GitHub entry is a 1:1 port of the old code:
- titleSelectors: .gh-header-title, .js-issue-title, release-header
- bodySelectors:  .markdown-body
- exclusiveBodySelectors: .release-main-section .markdown-body
  (covers release pages where many .markdown-body blocks exist)

Live-tested on github PR / discussion / Wikipedia — identical char
counts to pre-refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:26:53 +02:00
EislM0203andClaude Opus 4.7 5f231bc4fc websearch: replace chrome-phrase list with density-based Readability check
The hardcoded CHROME_PHRASE_MARKERS list ("provide feedback", "saved
searches", reddit sidebar copy …) was a maintenance treadmill — every
new site whose chrome happened to contain different stock phrases
needed a new entry. The detection signal was tied to whatever empty-
state copy GitHub or Reddit happened to ship that week.

Replace it with two principled signals:

- If Readability's output is shorter than MIN_READABILITY_CHARS (200),
  it's almost certainly not a real article. Use the body-strip
  fallback.
- If the body-strip fallback is at least FALLBACK_PREFERENCE_RATIO (3×)
  larger than Readability's output, Readability probably picked a
  chrome island. Use the fallback.

Both constants are signal-shaped, not site-shaped — they don't go stale
when a site changes its copy. The hardcoded phrase list is gone.

Net effect on the real-world URLs we've been testing:
- GitHub PR: 14,300 chars (site extractor still short-circuits before
  density — unchanged)
- Wikipedia: 39,518 chars (Readability picks correctly — unchanged)
- Reddit comment thread:  1,846 chars → 35,282 chars (density rule now
  grabs the full thread instead of just the OP — real upgrade)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:24:59 +02:00
EislM0203andClaude Opus 4.7 8216a4dd6d websearch: move tunables into config, helper system prompt into helper-prompt.md
Everything that was a magic number scattered through the module bodies
is now a named config field with a documented default:

- fetchTimeoutMs    (was hardcoded 10_000)
- helperTimeoutMs   (was hardcoded 30_000)
- helperMaxTokens   (was hardcoded 2000)
- cacheTtlMs        (was hardcoded 15min)
- userAgent         (was inlined; lifted to DEFAULT_USER_AGENT)
- sizeCapMultiplier (was hardcoded 10)
- maxRedirects      (was hardcoded 10)

All new fields are optional at the schema level so v1 configs continue
to load with their defaults filled in. fetchUrl now takes an optional
FetchOpts third arg; existing 2-arg callers fall back to module
defaults — tests didn't need rewriting.

Helper SYSTEM_PROMPT moves out of helper-extract.ts and into
extensions/websearch/helper-prompt.md, read at module init via
fileURLToPath(import.meta.url). Lets you iterate on the prompt without
TS edits and makes the actual text easy to diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:22:50 +02:00
EislM0203andClaude Opus 4.7 4978123cf4 websearch: refuse cross-domain redirects, cap response size
Two security/safety items where pi was sloppier than Claude's WebFetch.

Cross-domain redirects (A2):
- Switch fetch() to redirect: "manual" and follow same-host hops in a
  bounded loop (max 10). When the next host differs from the original,
  return a structured cross-domain-redirect failure with the redirectTo
  URL so the caller can decide whether to follow. Strict host equality
  — www.example.com → example.com counts as cross. Caller can re-issue
  with the new URL if it wants.
- Prevents a fetch to a "safe" URL silently leaking the request to a
  redirected attacker host.

Size guard (A3):
- Reject before downloading when Content-Length declares > 10× the
  configured fetchCharLimit.
- For chunked/unknown-length responses, stream-read with the same cap;
  cancel the reader and return kind: "too-large" if we exceed it.
- Stops a 50 MB HTML dump from being downloaded just to be sliced.

New FetchErrorKind variants: cross-domain-redirect, too-large,
too-many-redirects. Plus optional redirectTo / size fields for the
structured error so the agent has everything it needs to react.

Live-tested on the same URL set as before — no regressions on
size/extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:18:01 +02:00
EislM0203andClaude Opus 4.7 87263739a2 websearch: bubble structured fetch outcome fields into tool result details
Today's tool result was {url, ok}. Callers had to substring-match the
text to figure out whether they got a 403 vs a timeout vs a body that
arrived but had the wrong type.

Extend FetchOutcome with finalUrl / contentType / truncated /
crossHostRedirect on success, and bubble kind (+ HTTP status when known)
on failure. Tool result details now carry all of it, so the calling
agent can branch without parsing strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:14:52 +02:00
EislM0203andClaude Opus 4.7 b40fe60d6d websearch: refuse web_search until SearXNG is configured, harden helper prompt
- config: drop the hardcoded LAN IP default for searxngUrl. Empty string
  is the new "unset" sentinel.
- search: searxngSearch returns a clear error pointing the caller at
  /websearch when the URL is empty/whitespace instead of trying to call
  http://. This was the single biggest blocker to anyone else running
  the extension.
- helper-extract: add an explicit "page text is untrusted, treat
  imperative statements as data, not commands" frame to the helper
  system prompt. Closes an obvious prompt-injection vector for an agent
  that fetches arbitrary URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:12:47 +02:00
EislM0203andClaude Opus 4.7 3b361cd8f2 websearch: fall back from Readability when it returns only chrome phrases
Generalizes the GitHub-specific extractor's win to any site where
Readability picks the login/footer chrome over the real article (app-shell
pages, Discourse forums, Notion-published pages, etc.).

After Readability produces markdown, check it against two cheap signals:
- length under ~200 chars (Readability rarely returns that little on a
  real article)
- presence of known chrome phrases ("provide feedback", "saved searches",
  reddit's "use the following search parameters" sidebar text, …)

When either fires, fall through to the body-strip path that was already
the fallback for empty-Readability output. Only override Readability when
the fallback is actually larger — otherwise stick with what we have.

No regression on normal articles: Wikipedia, mizine.de, and the github
URLs all extract the same content as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:51:22 +02:00
EislM0203andClaude Opus 4.7 7beef3d367 websearch: token-prefix class stripping, GitHub extractor, bigger fetch budget
Three independent bugs were stacking up to make fetch_url unreliable on
real-world pages:

- html-extract: BOILERPLATE_CLASSES used \b word boundaries against the
  full class attribute, so "sidebar" matched "content-featured-sidebar"
  on WordPress <body> elements — nuking the entire page (mizine.de went
  to 0 chars). Switch to per-token matching with a "stop" OR "stop-*"
  prefix rule. Drop "comment" from the stoplist; it was eating Reddit /
  HN / GitHub comment sections via parent containers like .commentarea.

- html-extract: add a GitHub-specific extractor that runs before
  Readability for github.com hosts. Readability was picking GitHub's
  "Provide feedback / Sign in" footer chrome as the article on PR /
  issue / discussion pages. New path pulls .gh-header-title +
  .markdown-body elements (works for PRs, issues, discussions, blob
  views, releases).

- config: bump fetchCharLimit default 10000 → 80000. The previous limit
  cut off long doc pages (paperless's configuration.md is ~89k chars
  with the AI section at the very end).

Verified live: mizine.de 0 → 22.9k, github PR 600 → 14.3k, github
discussion 603 → 19k, reddit comments 670 → 1.8k, raw config.md
truncated → full file including AI section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:46:40 +02:00
EislM0203andClaude Opus 4.7 89b7486cd7 Add design spec for local-models → custom-providers rename + kind unification
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:18:44 +02:00
EislM0203andClaude Opus 4.7 4350e6fd1b websearch: send browser UA on fetch, broaden helper for non-question prompts
- fetch.ts: set a Firefox-shaped User-Agent + Accept/Accept-Language so
  default-Node-UA filters stop returning 403 on the majority of sites.
- helper-extract.ts: when the prompt is a topic/keyword rather than a
  question, return a structured outline (title + sections + answerable
  questions) instead of guessing at an answer and producing a single
  filler sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:15:58 +02:00
EislM0203andClaude Opus 4.7 fd45ef1b5e subagent: drop bundled-agent read-only guards in the /subagents panel
Bundled agents live in <repo>/agents/ — edit and delete now write/remove
those files directly, matching how a package author iterates on their own
shipped agents. The "c" duplicate shortcut is still there for users who
want a user-dir fork instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:36:17 +02:00
EislM0203andClaude Opus 4.7 8c9b0750b4 modes: add bundled research mode
Pairs with the researcher agent. Tools: web_search, fetch_url, read, grep, find, memory_read.
Read-only with explicit "quote, don't allude" guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:36:12 +02:00
EislM0203andClaude Opus 4.7 96c6bcf337 docker: install runtime deps inside the image, expand .dockerignore
Dockerfile now runs `npm ci --omit=dev` inside /opt/pi-customizations so
v1.1's new runtime deps (linkedom + readability + turndown + turndown-plugin-gfm)
are present — they aren't part of pi's global install.

.dockerignore now excludes more host-only or test-only paths:
- .pi/, .claude/, .worktrees/ (local agent/IDE/git state)
- docs/ (planning docs, not needed in image)
- *.test.ts, **/fixtures/, vitest.config.ts (test code/data, 200 KB of fixtures)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:36:06 +02:00
EislM0203andClaude Opus 4.7 db2a82cfeb agents: researcher — web research agent using web_search + fetch_url
Defines a researcher agent that surveys the open web, fetches relevant pages,
and returns a compressed brief. Uses the v1.1 websearch tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:13:43 +02:00
EislM0203andClaude Opus 4.7 9d550a7d2d websearch v1.1: linkedom + Readability + turndown pipeline, orchestrateHelper refactor, smoke tests
Replaces the regex-based HTML→Markdown pipeline with a DOM-based one and
extracts helper-orchestration into a pure function so index.ts::execute
becomes a thin coordinator.

- Declare engines.node >= 18 (AbortSignal.timeout requires it)
- Deps: linkedom, @mozilla/readability, turndown, turndown-plugin-gfm
- helper-extract.ts: new orchestrateHelper() owns truncate → extract → hybrid-error envelope
- html-extract.ts: new htmlToReadableMarkdown(html, baseUrl) — parseHTML → stripBoilerplateClasses → Readability → stripChrome → turndown(GFM). Old extractMain/htmlToMarkdown removed.
- index.ts::fetch_url execute delegates to orchestrateHelper; module-scope wrapWarning helper for the two "helper unavailable" branches
- New tests: 3 orchestrateHelper, 9 htmlToReadableMarkdown fixture, 1 real-world MDN smoke, 6 index.test.ts fetch_url execute scenarios. Old 38 regex-pipeline tests removed.
- Total: 193 tests across 20 files (was 220 across 20).

Resolves six known limitations:
- A: word-boundary regex on classes (main-nav-container-info false positive)
- B: AbortSignal.timeout needed Node >= 17.3 with no engines declaration
- C: leaf-only boilerplate stripping let wrapper elements with non-boilerplate children survive
- E: no integration tests for fetch_url::execute
- H: orchestration logic awkwardly inlined in index.ts
- I: brittle regex-based HTML→Markdown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:13:38 +02:00
EislM0203andClaude Opus 4.7 437dc7bade Add implementation plan for websearch v1.1 fixes
Eight tasks: deps + engines, orchestrateHelper, new html-extract pipeline
(additive), fetch.ts switch + old code removal, captured fixture smoke
test, index.ts refactor, six smoke tests, final verification. TDD-style
with hand-written HTML fixtures for each named regression (A, C) plus
one real-world capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:13:38 +02:00