@fastagent-sh/voicenote 0.18.3 → 0.18.5

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 CHANGED
@@ -6,7 +6,7 @@ CLI command: `vn`
6
6
 
7
7
  [中文文档 / Chinese documentation](README.zh-CN.md)
8
8
 
9
- Currently tuned for the PHILIPS VTR6500 voice recorder, but the workflow is generic: scan recordings under a mount point → transcribe with speaker diarization → GPT performs the necessary cleanup and process reconstruction inside the notes-generation stage → produce smart notes.
9
+ Currently tuned for the PHILIPS VTR6500 voice recorder, but the workflow is generic: scan recordings under a mount point → transcribe with speaker diarization → the selected summary model performs cleanup and process reconstruction → produce smart notes.
10
10
 
11
11
  **Two ways to use it:**
12
12
 
@@ -130,6 +130,24 @@ provider that actually failed. If that empties the chain, `vn run --mode notes`
130
130
  skips instead of paying for a transcript whose summary cannot happen.
131
131
  `vn doctor` prints the effective chain and the status of anything not ready.
132
132
 
133
+ ### DeepSeek notes
134
+
135
+ In the desktop app, open **Settings → Notes generation**, select **DeepSeek API**, enter your API key, and save. The model defaults to `deepseek-v4-flash`; enter `deepseek-v4-pro` to use Pro. ChatGPT sign-in is only shown for configurations that use ChatGPT. Audio transcription continues to use Volcano.
136
+
137
+ For the CLI, merge these values into `~/.config/voicenote/config.json`:
138
+
139
+ ```json
140
+ {
141
+ "VOICENOTE_PI_PROVIDER": "deepseek",
142
+ "VOICENOTE_PI_MODEL_SUMMARY": "deepseek-v4-flash",
143
+ "DEEPSEEK_API_KEY": "..."
144
+ }
145
+ ```
146
+
147
+ The notes-specific model setting takes precedence over `VOICENOTE_PI_MODEL`. When switching providers in the GUI, the model resets to that provider's default. API keys can also come from pi's authentication file or the environment; pi's authentication file takes priority over API-key environment variables.
148
+
149
+ Save changes, then check `vn doctor` or the app's Status panel. Credential checks confirm configuration, not API connectivity or account balance. Each provider in a fallback chain receives the same model ID, so configure DeepSeek on its own rather than mixing it with OpenAI.
150
+
133
151
  ## Usage
134
152
 
135
153
  ```bash
@@ -176,7 +194,9 @@ The install script writes an editable template:
176
194
  }
177
195
  ```
178
196
 
179
- Changes take effect on the next `vn run`. A legacy `~/.config/voicenote/speakers.json` is still read as a compatibility fallback.
197
+ Changes take effect on the next `vn run`. Config values and unquoted/double-quoted `.zshrc` exports support simple `$VAR` / `${VAR}` references to other settings and `$HOME`. Single-quoted shell values stay literal. Shell commands are never executed. Runtime references honor inherited environment values; scheduler comparisons resolve from files alone.
198
+
199
+ A legacy `~/.config/voicenote/speakers.json` is still read as a compatibility fallback.
180
200
 
181
201
  ## Workflow
182
202
 
@@ -219,7 +239,7 @@ The LaunchAgent invokes `vn run` every 60 seconds. It skips safely when no recor
219
239
 
220
240
  > Config changes (`config.json` or `~/.zshrc`) are picked up automatically by the background agent on its next run — no reinstall needed. The plist only snapshots real environment variables and pi's absolute path: **after changing `VOICENOTE_PI_BIN`, re-run `vn install-launch-agent` and reload** (`vn upgrade` regenerates the plist automatically). If pi is not signed in or ASR is not configured, the agent skips processing instead of burning ASR spend.
221
241
  >
222
- > Exception: if you `export http_proxy=...` directly in your shell (instead of using `LOCAL_PROXY_HOST`) and then run `vn install-launch-agent`, that real env value is snapshotted into the plist and keeps overriding later `LOCAL_PROXY_HOST` changes; re-run `vn install-launch-agent` to clear it. Prefer `LOCAL_PROXY_HOST`/`LOCAL_PROXY_PORT` for proxy configuration.
242
+ > Proxy values that match the file configuration, including expanded variable references, are not embedded and produce no override warning. Values supplied only by the shell, or differing from the files, are embedded as explicit overrides. To clear an unwanted override, update or unset the shell variable, then run `vn install-launch-agent --load`. Prefer `LOCAL_PROXY_HOST`/`LOCAL_PROXY_PORT` in `config.json` for proxy configuration.
223
243
 
224
244
  Logs:
225
245
 
@@ -259,7 +279,7 @@ A self-contained macOS `.app` (Tauri v2) for **non-terminal users**: the target
259
279
 
260
280
  **Positioning**: the GUI is only a "status dashboard + quick access to output" — it does **not** drive processing. The full pipeline runs autonomously every 60s via the background LaunchAgent using the bundled engine (it keeps running with the GUI closed).
261
281
 
262
- - First run: setup wizard (identity / Volcano keys / proxy) ChatGPT sign-in (no terminal on the device; uses `vn login`'s browser-callback flow)
282
+ - First run: settings (identity / Volcano keys / notes provider / proxy). Choose DeepSeek with an API key, or ChatGPT with browser sign-in (`vn login`'s browser-callback flow).
263
283
  - After that: the main view shows agent activity + recent notes (open note / open folder)
264
284
 
265
285
  ### What's bundled
@@ -271,7 +291,7 @@ A self-contained macOS `.app` (Tauri v2) for **non-terminal users**: the target
271
291
  | `vn` (compiled) | externalBin | pipeline + ChatGPT sign-in |
272
292
  | `bun` | externalBin | runs pi |
273
293
  | `ffprobe` (native arm64 static) | externalBin | audio duration (pi only needs ffprobe, not all of ffmpeg) |
274
- | `pi` + node_modules | resource | notes backend (ChatGPT Codex agent) |
294
+ | `pi` + node_modules | resource | notes backend (ChatGPT, OpenAI API, or DeepSeek) |
275
295
 
276
296
  At runtime, Rust generates a wrapper (`exec <bundled bun> <bundled pi/cli.js> "$@"`) and injects `VOICENOTE_PI_BIN` / `VOICENOTE_FFPROBE_BIN` into `vn`. Release builds are **universal** (x86_64 + arm64; vn/bun/ffprobe each merged with `lipo`; pi is JS and needs none).
277
297
 
@@ -317,12 +337,14 @@ irm https://raw.githubusercontent.com/fastagent-sh/voicenote/main/scripts/instal
317
337
 
318
338
  `install-app.sh` downloads the packaged `.app` from GitHub Releases → installs to `/Applications` → **removes the quarantine flag for the user** (Gatekeeper bypass for un-notarized builds) → opens it. The target machine needs no bun/pi/ffprobe/global vn (all bundled).
319
339
 
320
- **First launch**: the app lands on Settings fill in identity + your own Volcano ASR/TOS keys + proxy (BYOK) save click "Sign in to ChatGPT" in the Status panel (one-time browser authorization). The GUI then installs and loads the background LaunchAgent (pointing at the bundled engine); plug in the recorder and transcription + notes happen automatically.
340
+ **First launch**: the app lands on Settings. Fill in identity, your Volcano ASR/TOS keys, notes provider/model, and proxy as needed. For DeepSeek or OpenAI API, enter the corresponding API key; for ChatGPT, save and click "Sign in to ChatGPT" in the Status panel. Saving installs and loads the background LaunchAgent using the bundled engine. Once credentials are configured, plug in the recorder for automatic transcription and notes.
321
341
 
322
342
  > The background agent label is `sh.fastagent.voicenote` (same as the CLI version; only one exists per machine). If the `.app` is moved, open it once to recalibrate the plist.
323
343
 
324
344
  **Upgrades**: since 0.1.9 the app has a built-in updater — open the app → "Settings → Software update" → "Check for updates"; when a new version appears, click "Download & install"; the app restarts automatically with config/notes preserved. For first installs, or upgrades from 0.1.8 and earlier (which had no updater), re-run the one-line install script above.
325
345
 
346
+ > **Windows, from 0.1.11 or earlier**: those builds point their updater at the pre-rebrand repo, which still exists and stops at 0.1.11 — "Check for updates" therefore always reports "up to date". The bundle identifier changed in the same rebrand, so re-running the installer does *not* replace them; both copies stay installed under the same name. Uninstall the old VoiceNote (Settings → Apps) first, then run the one-line install. Config and notes are untouched by the uninstall.
347
+
326
348
  ### Maintainers: packaging + release
327
349
 
328
350
  **Automatic (recommended)**: push an `app-v*` tag to trigger `.github/workflows/release-app.yml`:
package/README.zh-CN.md CHANGED
@@ -321,6 +321,8 @@ irm https://raw.githubusercontent.com/fastagent-sh/voicenote/main/scripts/instal
321
321
 
322
322
  **升级**:0.1.9 起内置自动更新 —— 打开 app →「设置 → 软件更新」→「检查更新」,有新版点「下载并安装」,装好自动重启,配置/纪要均保留。首次安装、或从 0.1.8 及更早版本升级(它们还没有更新器),重跑上面的一键安装脚本即可。
323
323
 
324
+ > **Windows 从 0.1.11 及更早版本升级**:这些构建的更新地址指向改名前的旧仓库,而旧仓库至今存在、最新版停在 0.1.11,所以「检查更新」永远显示已是最新。同一次改名还换了 bundle identifier,重跑安装脚本**不会**覆盖旧版,两份会同名共存。先在「设置 → 应用」里卸载旧的 VoiceNote,再跑一键安装。卸载不影响配置和纪要。
325
+
324
326
  ### 维护者:打包 + 发布
325
327
 
326
328
  **自动(推荐)**:打 `app-v*` tag 触发 `.github/workflows/release-app.yml`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/voicenote",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
4
4
  "description": "Voice recordings → diarized transcripts → integrated semantic Markdown notes. Currently optimized for the PHILIPS VTR6500 recorder, but the workflow is generic.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { cac } from 'cac'
3
3
  import { deriveNoProxy, envKeysToEmbed, hydrateFromFileEnv, parseFileEnv } from './envConfig'
4
4
  import { parseLockOwner } from './runLock'
5
- import { parsePiAuthStatus, parseProviderChain, usableChain, type PiAuthStatus } from './piProvider'
5
+ import { defaultPiModel, parsePiAuthStatus, parseProviderChain, usableChain, type PiAuthStatus } from './piProvider'
6
6
  import { applyOutcome, buildJobsView, classify, emptyState, localIso, MAX_ATTEMPTS, migrateLegacyState, ownsOutput, parseJobsLimit, parseStateFile, parseStrictJson, patchJob, pruneUnseen, reconcileInterrupted, startAttempt, SUMMARY_FAILED_STATUS, type CurrentJob, type JobRecord, type StateFile } from './jobs'
7
7
  import { createHash, createHmac, randomUUID } from 'node:crypto'
8
8
  import { appendFile, chmod, mkdir, readFile, writeFile, copyFile, rename, unlink, stat, readdir, rm } from 'node:fs/promises'
@@ -13,7 +13,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
13
13
  import { spawn, spawnSync } from 'node:child_process'
14
14
  import os from 'node:os'
15
15
 
16
- const VERSION = '0.18.3'
16
+ const VERSION = '0.18.5'
17
17
  const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
18
18
  const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
19
19
  const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
@@ -137,6 +137,7 @@ const ENV_KEYS = [
137
137
  'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
138
138
  'LOCAL_PROXY_HOST', 'LOCAL_PROXY_PORT', 'LOCAL_NO_PROXY',
139
139
  'OPENAI_API_KEY',
140
+ 'DEEPSEEK_API_KEY',
140
141
  ]
141
142
 
142
143
  // Volcano endpoints (TOS object storage + openspeech ASR) should NEVER go through
@@ -208,20 +209,21 @@ function applyDerivedProxy(): void {
208
209
  }
209
210
  }
210
211
 
211
- // What the config files would provide for each ENV_KEY, independent of this
212
- // process's environment. Primary source is ~/.config/voicenote/config.json
212
+ // File values for each ENV_KEY. Hydration passes the current environment for
213
+ // variable references; scheduler comparison uses files alone. Primary source is
214
+ // ~/.config/voicenote/config.json
213
215
  // (ENV-style runtime keys at the top level; identity under `speakers`); the
214
216
  // legacy fallback is `export KEY=...` lines in ~/.zshrc, for CLI installs
215
217
  // that predate config.json. Precedence/expansion logic lives in envConfig.ts
216
218
  // (pure + tested). Two consumers: loadEnvConfig() hydrates these into
217
219
  // process.env for keys the real environment doesn't set, and launchAgentEnv()
218
220
  // uses them to decide which values are recoverable at run time.
219
- function fileProvidedEnv(): Record<string, string> {
221
+ function fileProvidedEnv(environment: Record<string, string | undefined> = {}): Record<string, string> {
220
222
  const data = loadJsonSync<Record<string, unknown>>(CONFIG_ENV_PATH, {})
221
223
  let zshrc: string | null = null
222
224
  const zshrcPath = join(os.homedir(), '.zshrc')
223
225
  if (existsSync(zshrcPath)) { try { zshrc = readFileSync(zshrcPath, 'utf8') } catch { zshrc = null } }
224
- return parseFileEnv(ENV_KEYS, data, zshrc, os.homedir())
226
+ return parseFileEnv(ENV_KEYS, data, zshrc, os.homedir(), environment)
225
227
  }
226
228
 
227
229
  let envConfigLoaded = false
@@ -230,7 +232,7 @@ function loadEnvConfig(): void {
230
232
  envConfigLoaded = true
231
233
  // Precedence: process.env > config.json (GUI) > ~/.zshrc (legacy); an
232
234
  // explicit empty string in the environment is never overridden.
233
- const toApply = hydrateFromFileEnv(ENV_KEYS, process.env, fileProvidedEnv())
235
+ const toApply = hydrateFromFileEnv(ENV_KEYS, process.env, fileProvidedEnv(process.env))
234
236
  for (const [key, v] of Object.entries(toApply)) { process.env[key] = v; hydratedEnvKeys.add(key) }
235
237
  // Derive http_proxy etc. from LOCAL_PROXY_HOST/PORT regardless of source, and
236
238
  // always keep Volcano hosts on NO_PROXY. (Runs even with no config files.)
@@ -1493,7 +1495,8 @@ function piProviderAuthStatus(provider: string): PiAuthStatus {
1493
1495
  const cached = piAuthStatusCache.get(provider)
1494
1496
  if (cached && Date.now() - cached.at < PI_AUTH_TTL_MS) return cached.status
1495
1497
  const inv = piInvocation(['auth', 'check', '--provider', provider, '--json'])
1496
- const out = spawnSync(inv.bin, inv.args, { encoding: 'utf8', timeout: PI_AUTH_PROBE_MS, windowsHide: true })
1498
+ // Bun's spawnSync needs an explicit env to inherit keys loaded from config.json.
1499
+ const out = spawnSync(inv.bin, inv.args, { encoding: 'utf8', timeout: PI_AUTH_PROBE_MS, windowsHide: true, env: process.env })
1497
1500
  // No pi binary is deterministic evidence in its own right — next run gets the
1498
1501
  // same answer — so it belongs with 'unusable', not with a probe that timed out.
1499
1502
  const status = (out.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'
@@ -1524,7 +1527,7 @@ function piProviderFor(): string {
1524
1527
  }
1525
1528
 
1526
1529
  function piCodexModelFor(): string {
1527
- return process.env.VOICENOTE_PI_MODEL_SUMMARY || process.env.VOICENOTE_PI_MODEL || 'gpt-5.5'
1530
+ return process.env.VOICENOTE_PI_MODEL_SUMMARY || process.env.VOICENOTE_PI_MODEL || defaultPiModel(piConfiguredProviders()[0]!)
1528
1531
  }
1529
1532
 
1530
1533
  function stripJsonFences(text: string): string {
@@ -1661,7 +1664,7 @@ function piSummaryToolsHint(contextDir: string): string {
1661
1664
  return `Before writing the notes you have two read-only tools: read and grep. Your current working directory (cwd) is \`${contextDir}\` (the configured notes/reference directory); use relative paths for grep/read.\n\nGoal: use existing context to align names, speakers, client/project names, product names, and domain terms in this note; do not maintain or assume a separate glossary.\n\nSuggested flow:\n- First extract the most likely client/project/product keywords from the title, filename, and transcript.\n- If a clear topic matches, prefer grep/read on related index pages, project docs, status records, or the 3-5 most recent related notes in the same directory; use them to identify Speaker B/C/F etc., common aliases, product names, and term spellings.\n- If no clear topic matches, grep the current directory with keywords and read only the few most relevant files.\n- Before output, do one names/terms lint pass: eliminate leftover Speaker A/B/C, obviously misheard names, product-name variants, and outdated names; when context is insufficient, keep the uncertainty — never guess.\n\nConstraints:\n- At most 10 tool calls total; if the transcript alone is sufficient, make none.\n- Read only within \`${contextDir}\`; skip directories that clearly involve personal privacy/credentials/finance (e.g. identity / credentials / finance).\n- Found information is only for consistency and background calibration; never write content absent from this transcript into the notes as new meeting facts.\n- Do not attempt to write files or call bash (those tools are not enabled).`
1662
1665
  }
1663
1666
 
1664
- // Summary runs on the pi-codex backend. The agent's working dir IS the knowledge
1667
+ // Summary runs through pi with the configured provider. The agent's working dir IS the knowledge
1665
1668
  // base, so read/grep/find operate there directly. If a configured context dir is
1666
1669
  // missing, say so loudly and run without tools rather than searching the wrong
1667
1670
  // tree (tools, the cwd hint, and the spawn cwd move together).
@@ -2248,12 +2251,6 @@ async function launchAgentEnv(): Promise<Record<string, string>> {
2248
2251
  // about: it may equally be a stale shell session, and it will keep
2249
2252
  // overriding config edits until the scheduler is reinstalled.
2250
2253
  //
2251
- // Known blind spot: the matrix compares each key against its OWN file value,
2252
- // so it can't see cross-key derivations. A real-env http_proxy is embedded
2253
- // as-is and will shadow a GUI edit to LOCAL_PROXY_HOST (different key name)
2254
- // until reinstall. Only no_proxy is special-cased (originals below) because
2255
- // WE synthesize it; http_proxy from a user's shell is left as a real value.
2256
- //
2257
2254
  // no_proxy/NO_PROXY carry a volcano-hosts merge we added; substitute the
2258
2255
  // pre-merge real-env original (or drop it entirely if we synthesized the
2259
2256
  // whole value) so the scheduler never freezes our merge over config edits.
@@ -2263,7 +2260,7 @@ async function launchAgentEnv(): Promise<Record<string, string>> {
2263
2260
  const { embed, frozenOverrides } = envKeysToEmbed(ENV_KEYS, embedEnv, hydratedEnvKeys, fileEnv)
2264
2261
  Object.assign(env, embed)
2265
2262
  for (const k of frozenOverrides) {
2266
- console.error(`Warning: environment ${k} differs from the config file value; the environment value is snapshotted into the scheduler and will override config edits until you re-run \`vn install-launch-agent\`.`)
2263
+ console.error(`Warning: environment ${k} overrides the config file. Update or unset it, then re-run \`vn install-launch-agent --load\` to apply the intended value.`)
2267
2264
  }
2268
2265
  // Embed pi's ABSOLUTE path so launchd resolves it regardless of the fixed plist
2269
2266
  // PATH (npm global bin can live outside it under nvm / custom prefixes). Resolve
@@ -2753,8 +2750,8 @@ async function collectDoctor() {
2753
2750
  const ff = await runCommand(ffprobeBin(), ['-version'], 5000)
2754
2751
  const v = config.volcano
2755
2752
  const tools = piSummaryTools()
2756
- // Configured vs effective, plus per-provider status: "why did my chain shrink"
2757
- // and "why is my misspelled provider still listed" are the same question.
2753
+ // An explicit status refresh must see newly saved keys and OAuth logins.
2754
+ piAuthStatusCache.clear()
2758
2755
  const configuredProviders = piConfiguredProviders()
2759
2756
  const effectiveProviders = piProviderCandidates()
2760
2757
  const providerStatus = Object.fromEntries(configuredProviders.map(p => [p, piProviderAuthStatus(p)]))
@@ -2989,7 +2986,7 @@ async function serve(): Promise<void> {
2989
2986
 
2990
2987
  const cli = cac('vn')
2991
2988
 
2992
- cli.command('run', 'Scan recorder and process recordings (Volcano ASR + pi-codex notes)')
2989
+ cli.command('run', 'Scan recorder and process recordings (Volcano ASR + pi notes)')
2993
2990
  .option('--mode <mode>', 'Output mode: notes (default) | transcript', { default: 'notes' })
2994
2991
  .option('--latest', 'Only process newest eligible recording')
2995
2992
  .option('--force', 'Reprocess already processed recordings')
package/src/envConfig.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // Pure logic behind cli.ts's env-config provenance. Extracted (no fs, no
2
2
  // process.env) so its invariants are testable — see envConfig.test.ts:
3
3
  //
4
- // 1. File precedence: config.json (GUI) wins over ~/.zshrc (legacy CLI);
5
- // $HOME tokens are expanded in both.
4
+ // 1. File precedence: config.json (GUI) wins over ~/.zshrc (legacy CLI).
5
+ // Simple $VAR/${VAR} references resolve from these values without running
6
+ // shell code; runtime hydration may also use inherited environment values.
6
7
  // 2. Hydration: only keys the real environment does NOT set are filled from
7
8
  // files — an explicit empty string in the environment (e.g.
8
9
  // VOICENOTE_PI_SUMMARY_TOOLS="") counts as set and is never overridden.
@@ -17,28 +18,46 @@
17
18
  // warn: it may equally be a stale shell session shadowing a fresh config
18
19
  // edit, and it will keep overriding until the scheduler is reinstalled.
19
20
 
20
- /** File-provided values for `keys`: config.json over zshrc, $HOME expanded. */
21
+ /** Omit `environment` when checking which values files can reproduce on their own. */
21
22
  export function parseFileEnv(
22
23
  keys: readonly string[],
23
24
  configData: Record<string, unknown>,
24
25
  zshrcContent: string | null,
25
26
  home: string,
27
+ environment: Record<string, string | undefined> = {},
26
28
  ): Record<string, string> {
27
- const out: Record<string, string> = {}
28
- const expand = (v: string) => v.replace(/\$\{?HOME\}?/g, home)
29
+ const raw = new Map<string, string>()
30
+ const literal = new Set<string>()
29
31
  for (const key of keys) {
30
32
  const v = configData[key]
31
- if (typeof v === 'string') out[key] = expand(v)
33
+ if (typeof v === 'string') { raw.set(key, v); continue }
34
+ const pattern = new RegExp(`(?:^|\\n)\\s*export\\s+${key}=(?:"([^"]*)"|'([^']*)'|([^\\s"'#]+))`)
35
+ const match = zshrcContent?.match(pattern)
36
+ const value = match?.slice(1).find(v => v !== undefined)
37
+ if (value !== undefined) raw.set(key, value)
38
+ if (match?.[2] !== undefined) literal.add(key)
32
39
  }
33
- if (zshrcContent !== null) {
34
- for (const key of keys) {
35
- if (out[key] !== undefined) continue // config.json wins
36
- const pattern = new RegExp(`(?:^|\\n)\\s*export\\s+${key}=(?:"([^"]*)"|'([^']*)'|([^\\s"'#]+))`)
37
- const value = zshrcContent.match(pattern)?.slice(1).find(v => v !== undefined)
38
- if (value !== undefined) out[key] = expand(value)
39
- }
40
+ const resolved = new Map<string, string>()
41
+ const visiting = new Set<string>()
42
+ const resolve = (key: string): string => {
43
+ const cached = resolved.get(key)
44
+ if (cached !== undefined) return cached
45
+ if (visiting.has(key)) throw new Error(`Circular config variable reference: ${key}`)
46
+ visiting.add(key)
47
+ const value = raw.get(key)!
48
+ const expanded = literal.has(key) ? value : value.replace(/\\(\$)|\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (token, escaped, braced, bare) => {
49
+ if (escaped) return escaped
50
+ const ref = braced || bare
51
+ if (ref === 'HOME') return home
52
+ // Unknown references stay literal, so they cannot look recoverable from files.
53
+ if (Object.hasOwn(environment, ref) && environment[ref] !== undefined) return environment[ref]!
54
+ return raw.has(ref) ? resolve(ref) : token
55
+ })
56
+ visiting.delete(key)
57
+ resolved.set(key, expanded)
58
+ return expanded
40
59
  }
41
- return out
60
+ return Object.fromEntries(Array.from(raw.keys(), key => [key, resolve(key)]))
42
61
  }
43
62
 
44
63
  /** Which keys to copy from fileEnv into an environment (invariant 2). */
package/src/piProvider.ts CHANGED
@@ -3,6 +3,10 @@
3
3
 
4
4
  export const DEFAULT_PI_PROVIDERS = ['openai-codex', 'openai']
5
5
 
6
+ export function defaultPiModel(provider: string): string {
7
+ return provider === 'deepseek' ? 'deepseek-v4-flash' : 'gpt-5.5'
8
+ }
9
+
6
10
  export function parseProviderChain(raw: string | undefined): string[] {
7
11
  const parsed = Array.from(new Set((raw ?? '').split(',').map(s => s.trim()).filter(Boolean)))
8
12
  return parsed.length ? parsed : [...DEFAULT_PI_PROVIDERS]