pi-quiver 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v4.5.0 - 2026-08-29
12
+
13
+ - Settings resolution: every pi-quiver setting is now read from an optional `"quiver"` root object in `settings.json` (`quiver.<key>`), grouping the four legacy flat keys (`fastMode`, `sessionAutoName`, `swordHeader`, `providerStallWatchdog`) plus any future key. The flat top-level form keeps working, but only for those four legacy keys - it is frozen there and never extended to new settings. Within a layer, `quiver.<key>` wins over flat `<key>` by presence; malformed values and flat/nested duplicates now emit a warning instead of resolving silently.
14
+
11
15
  ## v4.4.0 - 2026-08-26
12
16
 
13
17
  - doc_to_md: backend ladder now tries a system Python >= 3.12 with `pymupdf4llm` importable, then a one-time managed venv (bootstrapped at the version pin into a per-OS cache dir) between the existing `uv` and `unpdf` rungs. Data plane extracted to pi-free `lib/doc-to-md-core.ts`; new `pi-quiver doc-to-md <path>` CLI subcommand and `doc-to-md` Claude Code skill. `uv`/`soffice` detection is now spawn-based (Windows-correct). The bundled Python conversion script is now resolved from the package root, fixing a path bug that broke it under the bundled CLI.
package/README.md CHANGED
@@ -137,45 +137,71 @@ None is a hard install-time dependency of the package; they are tools you provid
137
137
 
138
138
  ### Opt-in extension config
139
139
 
140
- These extensions are opt-in via `settings.json` (project `.pi/settings.json` overrides the global agent-dir layer):
140
+ These extensions are opt-in via `settings.json` (project `.pi/settings.json` overrides the global agent-dir layer), nested under an optional `"quiver"` root:
141
141
 
142
142
  ```jsonc
143
143
  {
144
- "sessionAutoName": {
145
- "enabled": false,
146
- "ghosttyTab": true,
147
- "rules": [],
148
- "deny": [],
149
- "revisitFirstTurn": 0,
150
- "revisitEveryTurns": 0
151
- }, // or boolean shorthand
152
- "swordHeader": false, // or { "enabled": true }
153
- "fastMode": false, // or { "enabled": true }
154
- "providerStallWatchdog": false // or { "enabled": true }
144
+ "quiver": {
145
+ "sessionAutoName": {
146
+ "enabled": false,
147
+ "ghosttyTab": true,
148
+ "rules": [],
149
+ "deny": [],
150
+ "revisitFirstTurn": 0,
151
+ "revisitEveryTurns": 0
152
+ }, // or boolean shorthand
153
+ "swordHeader": false, // or { "enabled": true }
154
+ "fastMode": false, // or { "enabled": true }
155
+ "providerStallWatchdog": false // or { "enabled": true }
156
+ }
155
157
  }
156
158
  ```
157
159
 
160
+ Each key resolves independently: within a layer, `quiver.<key>` wins over a
161
+ flat top-level `<key>` by presence alone (even when the winning value is
162
+ malformed); across layers, each layer's candidate is validated into a partial
163
+ patch and `Object.assign`ed over the accumulator in layer order (project
164
+ last), so project fields override matching global fields while unmatched
165
+ global fields survive - a flat-vs-nested shape difference between layers
166
+ never changes this. The flat top-level form still works, but only for four
167
+ legacy keys, frozen at `fastMode`, `sessionAutoName`, `swordHeader`, and
168
+ `providerStallWatchdog` - never extended to new settings (see [Migrating from
169
+ flat keys](#migrating-from-flat-keys)).
170
+
171
+ Worked mixed-shape example: global `settings.json` has flat
172
+ `"fastMode": false`, project `.pi/settings.json` has
173
+ `"quiver": { "fastMode": { "enabled": true } }`. The project layer's
174
+ patch (`{ "enabled": true }`) is `Object.assign`ed over the accumulator
175
+ seeded from global's patch, so the resolved config is `{ "enabled": true }` -
176
+ same outcome here because `enabled` is the only field either layer sets, but
177
+ the merge is per-field: a global field with no project counterpart would
178
+ survive untouched.
179
+
158
180
  `sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `rules` appends house conventions to the naming prompt (later rules win when they conflict with the built-ins). Literal, case-insensitive `deny` phrases are stripped from every name; whitespace inside a phrase is loose, so `"acme corp"` also catches `AcmeCorp`. `revisitFirstTurn` re-evaluates the name once that many model round trips have completed, while `revisitEveryTurns` does so at every multiple; both default to `0` (off) because each revisit costs another short LLM call. For example, `10` and `100` mark round trips 10, 100, 200, 300. Revisits only run when the agent has fully settled (idle, nothing queued) - an automated multi-turn run such as a subagent chain is never renamed or delayed mid-flight; cadence points it crossed fire once, at the settle. A machine-generated name is replaced when stale. A name set by a human is never overwritten: the extension strongly prefers it, and announces a suggestion only when the work has clearly moved on. Counts come from the persisted transcript, so they survive resume.
159
181
 
160
182
  `fastMode` only affects `claude-opus-4-8` and `claude-opus-5` requests on Anthropic's `anthropic-messages` API; enabling it opts into premium fast-mode pricing. `--fast` forces it on for one launch; `/fast on|off` toggles live. Proxy providers (opencode, cloudflare-ai-gateway) are excluded. `fastMode`'s header injection needs the `before_provider_headers` hook (pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5); on older pi the beta header is silently not sent. See [doc/fetch.md](doc/fetch.md) and [doc/doc-to-md.md](doc/doc-to-md.md) for the ingestion tools' full reference; session-name/sword-header behavior above is complete.
161
183
 
162
184
  `pi-ai` prices every fast request at standard rates - it has no `usage.speed` support and no request-level pricing modifier - so `fastMode` corrects the reported cost itself: a `message_end` handler scales all four `usage.cost` components by `FAST_MODE_COST_MULTIPLIER` (2x) and returns the corrected message. Persisted session JSONL and pi's own native cost display are always exact, since they're written from this corrected message. pi-cohort's live `Σ$` reflects the correction only when pi-quiver's `message_end` handler runs before pi-cohort's - best-effort, depending on extension load order - and is reconciled on pi-cohort's next `session_start` regardless. The upstream fix (teaching `pi-ai`'s `Usage`/`calculateCost` about `usage.speed`) is the better long-term path and is tracked separately.
163
185
 
164
- Recommended explicit retry and watchdog settings:
186
+ Recommended explicit retry and watchdog settings - `providerStallWatchdog`
187
+ nests under `quiver`, while pi-core's own `retry` stays flat beside it (it is
188
+ not a pi-quiver setting and is never nested):
165
189
 
166
190
  ```json
167
191
  {
192
+ "quiver": {
193
+ "providerStallWatchdog": {
194
+ "enabled": true,
195
+ "firstEventMs": 20000,
196
+ "warningMs": 120000,
197
+ "recoveryMs": 240000,
198
+ "maxStallRetries": 3
199
+ }
200
+ },
168
201
  "retry": {
169
202
  "enabled": true,
170
203
  "maxRetries": 3,
171
204
  "baseDelayMs": 2000
172
- },
173
- "providerStallWatchdog": {
174
- "enabled": true,
175
- "firstEventMs": 20000,
176
- "warningMs": 120000,
177
- "recoveryMs": 240000,
178
- "maxStallRetries": 3
179
205
  }
180
206
  }
181
207
  ```
@@ -205,6 +231,30 @@ Operational notes:
205
231
  - **A watchdog abort that the provider ignores escalates after a fixed 10s.** Any post-abort stream event re-arms that deadline (bytes prove only that the connection was alive at that instant), so a stream that emits a straggler and then wedges still escalates 10s after its last event. This reduces the hang; it cannot force the provider to stop, and undici's timeouts remain the final backstop.
206
232
  - **Headless runs report on stderr.** In `print`/`json` mode pi binds a no-op UI, so watchdog notices go out via `console.warn`. Nothing is ever written to stdout, which `json` mode uses for its protocol. In TUI and RPC the notices render as main-window notifications, not the bottom status line.
207
233
 
234
+ ### Migrating from flat keys
235
+
236
+ The flat top-level form (`"fastMode": ...` etc. directly under `settings.json`)
237
+ is the outdated configuration style. It is legacy-frozen to exactly the four
238
+ keys above - `fastMode`, `sessionAutoName`, `swordHeader`,
239
+ `providerStallWatchdog` - and will never gain a fifth. To migrate, wrap your
240
+ existing keys under `"quiver": { ... }` and delete the flat copies:
241
+
242
+ ```jsonc
243
+ // before
244
+ { "fastMode": true }
245
+
246
+ // after
247
+ { "quiver": { "fastMode": true } }
248
+ ```
249
+
250
+ Until you delete the flat copy, having both set is not an error - the
251
+ duplicate resolves per the precedence above (nested wins within a layer) -
252
+ but it emits a warning notification, deduped per process (each unique
253
+ message fires at most once per pi process - in practice once per interactive
254
+ session) until the flat entry is removed. Every new pi-quiver setting introduced after this change
255
+ (for example a future `slack` key) is nested-only from day one: it has no
256
+ flat form to fall back to.
257
+
208
258
  ## Claude Code support
209
259
 
210
260
  `fetch`'s core (`lib/fetch-core.ts`) is also published as a CLI, so Claude Code can use the same routing, size gate, and spill behavior as pi's native tool - without pi ever seeing Claude-only files.
@@ -116,7 +116,7 @@ export default function (pi: ExtensionAPI) {
116
116
  const readFlag = (): boolean => pi.getFlag("fast") === true;
117
117
 
118
118
  const resolveState = (ctx: ExtensionContext): boolean => {
119
- const config = resolveConfig(ctx.cwd, "fastMode", DEFAULT_CONFIG, coerce).enabled;
119
+ const config = resolveConfig(ctx.cwd, "fastMode", DEFAULT_CONFIG, coerce, (m) => ctx.ui.notify(m, "warning")).enabled;
120
120
  enabled = resolveEnabled({ config, flag: readFlag(), live: liveOverride });
121
121
  return enabled;
122
122
  };
@@ -91,8 +91,8 @@ export function resolveRetryMaxRetries(cwd: string): number {
91
91
  return maxRetries;
92
92
  }
93
93
 
94
- export function resolveWatchdogConfig(cwd: string): ConfigValidation {
95
- const candidate = resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce);
94
+ export function resolveWatchdogConfig(cwd: string, warn?: (msg: string) => void): ConfigValidation {
95
+ const candidate = resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce, warn);
96
96
  if (candidate.blockIsObject === true && candidate.maxStallRetries === undefined) {
97
97
  candidate.maxStallRetries = resolveRetryMaxRetries(cwd);
98
98
  }
@@ -278,7 +278,7 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
278
278
  ui = ctx.ui;
279
279
  hasUI = ctx.hasUI;
280
280
  if (!config) {
281
- const resolved = resolveWatchdogConfig(ctx.cwd);
281
+ const resolved = resolveWatchdogConfig(ctx.cwd, (m) => announce(m, "warning"));
282
282
  if (!resolved.ok) {
283
283
  disabled = true;
284
284
  announce(`providerStallWatchdog disabled: ${resolved.error}`, "warning");
@@ -96,7 +96,7 @@ export function coerce(raw: unknown): Partial<Config> | undefined {
96
96
  }
97
97
 
98
98
  function loadConfig(ctx: ExtensionContext): Config {
99
- return resolveConfig(ctx.cwd, "sessionAutoName", DEFAULT_CONFIG, coerce);
99
+ return resolveConfig(ctx.cwd, "sessionAutoName", DEFAULT_CONFIG, coerce, (m) => ctx.ui.notify(m, "warning"));
100
100
  }
101
101
 
102
102
  type ContentBlock = { type?: string; text?: string };
@@ -66,7 +66,7 @@ function renderSwordLines(theme: Theme): string[] {
66
66
  export default function (pi: ExtensionAPI) {
67
67
  pi.on("session_start", async (_event, ctx) => {
68
68
  if (ctx.mode !== "tui") return;
69
- const cfg = resolveConfig(ctx.cwd, "swordHeader", DEFAULT_CONFIG, coerce);
69
+ const cfg = resolveConfig(ctx.cwd, "swordHeader", DEFAULT_CONFIG, coerce, (m) => ctx.ui.notify(m, "warning"));
70
70
  if (!cfg.enabled) return;
71
71
  ctx.ui.setHeader((_tui, theme) => ({
72
72
  render(_width: number): string[] {
@@ -11,6 +11,10 @@
11
11
  * correct when these extensions are consumed as a git-tag-pinned package -
12
12
  * unlike deriving the path from `import.meta.url`, which only held while an
13
13
  * extension lived inside `<agentHome>/extensions/`.
14
+ *
15
+ * Within each layer, a nested `quiver.<key>` takes precedence over the flat
16
+ * `<key>` by presence alone (even when malformed); the flat top-level
17
+ * fallback is frozen to the pre-quiver LEGACY_FLAT_KEYS and never extended.
14
18
  */
15
19
 
16
20
  import { readFileSync } from "node:fs";
@@ -29,21 +33,60 @@ export function settingsPaths(cwd: string): string[] {
29
33
  return [join(getAgentDir(), "settings.json"), join(cwd, ".pi", "settings.json")];
30
34
  }
31
35
 
36
+ /** Flat top-level fallback is frozen to these pre-quiver keys; never extend. */
37
+ const LEGACY_FLAT_KEYS = new Set(["fastMode", "sessionAutoName", "swordHeader", "providerStallWatchdog"]);
38
+
39
+ const emittedWarnings = new Set<string>();
40
+
41
+ function emitWarning(warn: ((message: string) => void) | undefined, message: string): void {
42
+ if (!warn || emittedWarnings.has(message)) return;
43
+ emittedWarnings.add(message);
44
+ warn(message);
45
+ }
46
+
32
47
  /**
33
- * Resolve a single extension config key across the settings layers. `coerce`
34
- * validates each layer's raw value into a partial patch (or `undefined` to
48
+ * Resolve a single extension config key across the settings layers.
49
+ * `quiver.<key>` wins over flat `<key>` within a layer by presence (even
50
+ * when malformed); flat fallback exists only for LEGACY_FLAT_KEYS. `coerce`
51
+ * validates the layer's candidate into a partial patch (or `undefined` to
35
52
  * skip); patches merge over `defaults` in layer order (project wins).
53
+ * `warn` receives one sentence per malformed or flat/nested-duplicated key,
54
+ * deduped per process.
36
55
  */
37
56
  export function resolveConfig<T extends object>(
38
57
  cwd: string,
39
58
  key: string,
40
59
  defaults: T,
41
60
  coerce: (raw: unknown) => Partial<T> | undefined,
61
+ warn?: (message: string) => void,
42
62
  ): T {
43
63
  const cfg: T = { ...defaults };
64
+ let nestedSeen = false;
65
+ let flatSeen = false;
44
66
  for (const path of settingsPaths(cwd)) {
45
- const patch = coerce(readSettings(path)?.[key]);
67
+ const settings = readSettings(path);
68
+ if (!settings) continue;
69
+ let root = settings.quiver;
70
+ if (root !== undefined && (root === null || typeof root !== "object" || Array.isArray(root))) {
71
+ emitWarning(warn, `pi-quiver: "quiver" in ${path} is not an object; ignored.`);
72
+ root = undefined;
73
+ }
74
+ const nested = root as Record<string, unknown> | undefined;
75
+ const hasNested = nested !== undefined && Object.hasOwn(nested, key);
76
+ const hasFlat = Object.hasOwn(settings, key);
77
+ nestedSeen ||= hasNested;
78
+ flatSeen ||= hasFlat;
79
+ if (!hasNested && !(hasFlat && LEGACY_FLAT_KEYS.has(key))) continue;
80
+ const candidate = hasNested ? nested![key] : settings[key];
81
+ const patch = coerce(candidate);
46
82
  if (patch) Object.assign(cfg, patch);
83
+ else emitWarning(warn, `pi-quiver: "${key}" in ${path} has an unrecognized value; ignored.`);
84
+ }
85
+ if (nestedSeen && flatSeen) {
86
+ emitWarning(
87
+ warn,
88
+ `pi-quiver: "${key}" is set both flat and under "quiver" (nested wins within a layer; across layers the project layer wins regardless of shape) - move the flat entry under "quiver".`,
89
+ );
47
90
  }
48
91
  return cfg;
49
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",