@fastagent-sh/voicenote 0.19.0 → 0.21.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/README.md +25 -6
- package/README.zh-CN.md +22 -4
- package/package.json +1 -1
- package/src/cli.ts +46 -16
package/README.md
CHANGED
|
@@ -113,6 +113,8 @@ Optional settings:
|
|
|
113
113
|
"VOICENOTE_RECORD_DIR": "/Volumes/VTR6500/RECORD",
|
|
114
114
|
"VOICENOTE_MAX_AGE_HOURS": "48",
|
|
115
115
|
"VOICENOTE_PI_BIN": "pi",
|
|
116
|
+
"VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
|
|
117
|
+
"PI_CODING_AGENT_DIR": "$HOME/.config/voicenote/pi-agent",
|
|
116
118
|
"VOICENOTE_PI_THINKING": "high",
|
|
117
119
|
"VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
|
|
118
120
|
"VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
|
|
@@ -121,12 +123,29 @@ Optional settings:
|
|
|
121
123
|
|
|
122
124
|
### Which model writes the notes
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
`VOICENOTE_PI_MODEL` is passed straight to pi as `--model`. pi accepts
|
|
127
|
+
`provider/id`, so one value pins both (`openai-codex/gpt-5.6-sol`). Leave it unset
|
|
128
|
+
and pi's own configured default model is used.
|
|
129
|
+
|
|
130
|
+
Credentials always belong to pi (`pi` → `/login <provider>`, or a provider API key
|
|
131
|
+
in the environment); voicenote never picks a provider and never falls back to a
|
|
132
|
+
second one. If pi fails, the transcript is kept and the summary can be retried
|
|
133
|
+
with `vn run --latest`.
|
|
134
|
+
|
|
135
|
+
### Credentials of their own
|
|
136
|
+
|
|
137
|
+
`PI_CODING_AGENT_DIR` relocates pi's config directory, which is where it keeps
|
|
138
|
+
`auth.json`. Point it at a voicenote-owned directory and `vn login` writes there,
|
|
139
|
+
pi refreshes the tokens there, and an interactive pi session cannot clobber
|
|
140
|
+
them — it rewrites its own `auth.json` wholesale on exit, which has silently
|
|
141
|
+
dropped providers before:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
echo '{"env":{"PI_CODING_AGENT_DIR":"$HOME/.config/voicenote/pi-agent"}}' | vn config set
|
|
145
|
+
vn login # signs in and stores credentials in that directory
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`vn doctor` prints the path it will read (`pi.auth=...`).
|
|
130
149
|
|
|
131
150
|
`DEEPSEEK_API_KEY` and `OPENAI_API_KEY` in the config are only forwarded to pi's
|
|
132
151
|
environment for providers that read them.
|
package/README.zh-CN.md
CHANGED
|
@@ -113,6 +113,8 @@ brew install ffmpeg
|
|
|
113
113
|
"VOICENOTE_RECORD_DIR": "/Volumes/VTR6500/RECORD",
|
|
114
114
|
"VOICENOTE_MAX_AGE_HOURS": "48",
|
|
115
115
|
"VOICENOTE_PI_BIN": "pi",
|
|
116
|
+
"VOICENOTE_PI_MODEL": "openai-codex/gpt-5.6-sol",
|
|
117
|
+
"PI_CODING_AGENT_DIR": "$HOME/.config/voicenote/pi-agent",
|
|
116
118
|
"VOICENOTE_PI_THINKING": "high",
|
|
117
119
|
"VOICENOTE_PI_SUMMARY_TOOLS": "read,grep",
|
|
118
120
|
"VOICENOTE_CONTEXT_DIR": "/Users/you/vault"
|
|
@@ -121,10 +123,26 @@ brew install ffmpeg
|
|
|
121
123
|
|
|
122
124
|
### 纪要用哪个模型
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
`VOICENOTE_PI_MODEL` 会原样传给 pi 的 `--model`。pi 支持 `provider/id` 写法, 所以
|
|
127
|
+
一个值就能同时定 provider 和模型(`openai-codex/gpt-5.6-sol`)。不设就用 pi 自己配置
|
|
128
|
+
的默认模型。
|
|
129
|
+
|
|
130
|
+
凭证始终属于 pi(`pi` → `/login <provider>`, 或环境里的 provider API key); voicenote
|
|
131
|
+
不选 provider, 也不会回退到第二个。pi 失败时 transcript 会保留, 用 `vn run --latest`
|
|
132
|
+
重试纪要即可。
|
|
133
|
+
|
|
134
|
+
### 给 voicenote 一份独立凭证
|
|
135
|
+
|
|
136
|
+
`PI_CODING_AGENT_DIR` 可以改掉 pi 的配置目录, `auth.json` 就存在那里。指向一个
|
|
137
|
+
voicenote 自己的目录后, `vn login` 写入这里, pi 也在这里刷新 token, 交互式 pi 会话
|
|
138
|
+
碰不到它 —— 那边退出时会整份覆写自己的 `auth.json`, 之前就把 provider 条目悄悄弄丢过:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
echo '{"env":{"PI_CODING_AGENT_DIR":"$HOME/.config/voicenote/pi-agent"}}' | vn config set
|
|
142
|
+
vn login # 登录后凭证就存在该目录
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`vn doctor` 的 `pi.auth=` 一行会打印实际读取的路径。
|
|
128
146
|
|
|
129
147
|
配置里的 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` 只是透传给 pi 的环境变量。
|
|
130
148
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastagent-sh/voicenote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
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
|
@@ -12,7 +12,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
12
12
|
import { spawn, spawnSync } from 'node:child_process'
|
|
13
13
|
import os from 'node:os'
|
|
14
14
|
|
|
15
|
-
const VERSION = '0.
|
|
15
|
+
const VERSION = '0.21.0'
|
|
16
16
|
const LAUNCH_AGENT_LABEL = 'sh.fastagent.voicenote'
|
|
17
17
|
const LAUNCH_AGENT_LABEL_LEGACY = 'com.kid7st.voicenote' // pre-fastagent installs; cleaned up on install
|
|
18
18
|
const TASK_NAME = 'VoiceNote' // Windows Task Scheduler name (mac uses LAUNCH_AGENT_LABEL)
|
|
@@ -31,7 +31,15 @@ const LOG_DIR = join(STATE_DIR, 'logs')
|
|
|
31
31
|
const LOCK_PATH = join(STATE_DIR, 'run.lock')
|
|
32
32
|
const SPEAKERS_PATH = join(CONFIG_DIR, 'speakers.json')
|
|
33
33
|
const CONFIG_ENV_PATH = join(CONFIG_DIR, 'config.json')
|
|
34
|
-
|
|
34
|
+
// pi keeps credentials in its config dir, which PI_CODING_AGENT_DIR relocates.
|
|
35
|
+
// Point it at a voicenote-owned directory to get an auth.json that only the
|
|
36
|
+
// pipeline reads and refreshes: an interactive pi session rewrites its own
|
|
37
|
+
// auth.json wholesale on exit and has already dropped entries that way.
|
|
38
|
+
// Resolved per call — the env is hydrated from config.json after module load.
|
|
39
|
+
function piAuthPath(): string {
|
|
40
|
+
const dir = process.env.PI_CODING_AGENT_DIR || join(os.homedir(), '.pi', 'agent')
|
|
41
|
+
return join(expandHome(dir), 'auth.json')
|
|
42
|
+
}
|
|
35
43
|
|
|
36
44
|
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.wma', '.aac', '.flac'])
|
|
37
45
|
|
|
@@ -125,7 +133,9 @@ const ENV_KEYS = [
|
|
|
125
133
|
'VOLCANO_TOS_KEEP',
|
|
126
134
|
'VOICENOTE_PI_BIN',
|
|
127
135
|
'VOICENOTE_PI_CLI',
|
|
136
|
+
'PI_CODING_AGENT_DIR',
|
|
128
137
|
'VOICENOTE_FFPROBE_BIN',
|
|
138
|
+
'VOICENOTE_PI_MODEL',
|
|
129
139
|
'VOICENOTE_PI_THINKING',
|
|
130
140
|
'VOICENOTE_PI_SUMMARY_TOOLS',
|
|
131
141
|
'VOICENOTE_CONTEXT_DIR',
|
|
@@ -174,6 +184,14 @@ function systemProxyUrl(): string | null {
|
|
|
174
184
|
} catch { return null }
|
|
175
185
|
}
|
|
176
186
|
|
|
187
|
+
// Bun's child_process does NOT hand a child the proxy variables this process set
|
|
188
|
+
// on process.env (http_proxy/https_proxy/no_proxy and their uppercase forms are
|
|
189
|
+
// special-cased internally; only all_proxy survives). Every spawn that must reach
|
|
190
|
+
// the network through the proxy has to pass them explicitly, so record them here.
|
|
191
|
+
// Without this, a scheduler run whose proxy comes from config.json rather than a
|
|
192
|
+
// real shell env leaves pi with no proxy at all — it fails with `fetch failed`.
|
|
193
|
+
const childProxyEnv: Record<string, string> = {}
|
|
194
|
+
|
|
177
195
|
function applyDerivedProxy(): void {
|
|
178
196
|
const host = process.env.LOCAL_PROXY_HOST
|
|
179
197
|
const port = process.env.LOCAL_PROXY_PORT
|
|
@@ -190,6 +208,7 @@ function applyDerivedProxy(): void {
|
|
|
190
208
|
const needs = (k: string) => !process.env[k] || process.env[k]!.includes('${')
|
|
191
209
|
for (const k of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) {
|
|
192
210
|
if (needs(k)) { process.env[k] = url; hydratedEnvKeys.add(k) } // derived, not real env
|
|
211
|
+
childProxyEnv[k] = process.env[k]!
|
|
193
212
|
}
|
|
194
213
|
}
|
|
195
214
|
// no_proxy/NO_PROXY: seed a base when a proxy is active, then always merge
|
|
@@ -200,6 +219,7 @@ function applyDerivedProxy(): void {
|
|
|
200
219
|
for (const k of ['no_proxy', 'NO_PROXY'] as const) {
|
|
201
220
|
const r = deriveNoProxy(process.env[k], premergeRealNoProxy[k], hydratedEnvKeys.has(k), !!url, baseNoProxy, VOLCANO_NO_PROXY_HOSTS)
|
|
202
221
|
process.env[k] = r.runtime
|
|
222
|
+
childProxyEnv[k] = r.runtime
|
|
203
223
|
if (r.hydrate) hydratedEnvKeys.add(k)
|
|
204
224
|
if (r.capture !== undefined) premergeRealNoProxy[k] = r.capture
|
|
205
225
|
}
|
|
@@ -1308,15 +1328,16 @@ function piInvocation(args: string[]): { bin: string; args: string[] } {
|
|
|
1308
1328
|
// ───────────────────────────────────────────────────────────────────────
|
|
1309
1329
|
|
|
1310
1330
|
async function persistPiOAuth(providerId: string, creds: Record<string, unknown>): Promise<void> {
|
|
1311
|
-
|
|
1331
|
+
const authPath = piAuthPath()
|
|
1332
|
+
await mkdir(dirname(authPath), { recursive: true })
|
|
1312
1333
|
let existing: Json = {}
|
|
1313
|
-
if (existsSync(
|
|
1314
|
-
try { existing = JSON.parse(await readFile(
|
|
1334
|
+
if (existsSync(authPath)) {
|
|
1335
|
+
try { existing = JSON.parse(await readFile(authPath, 'utf8')) as Json } catch (e) { warnSideEffect(`parse ${authPath}`, e) }
|
|
1315
1336
|
}
|
|
1316
1337
|
existing[providerId] = { type: 'oauth', ...creds }
|
|
1317
|
-
const tmp = `${
|
|
1338
|
+
const tmp = `${authPath}.tmp-${process.pid}`
|
|
1318
1339
|
await writeFile(tmp, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
|
|
1319
|
-
await rename(tmp,
|
|
1340
|
+
await rename(tmp, authPath)
|
|
1320
1341
|
}
|
|
1321
1342
|
|
|
1322
1343
|
async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?: (o: Record<string, unknown>) => void }): Promise<void> {
|
|
@@ -1366,7 +1387,7 @@ async function loginChatGPT(opts: { json?: boolean; deviceCode?: boolean; emit?:
|
|
|
1366
1387
|
}
|
|
1367
1388
|
await persistPiOAuth(oauth.openaiCodexOAuthProvider.id, creds)
|
|
1368
1389
|
if (json) emit({ event: 'success', provider: oauth.openaiCodexOAuthProvider.id })
|
|
1369
|
-
else console.log(`\n✓ Signed in. Credentials saved to ${
|
|
1390
|
+
else console.log(`\n✓ Signed in. Credentials saved to ${piAuthPath()}. Verify with: vn doctor`)
|
|
1370
1391
|
} catch (e: any) {
|
|
1371
1392
|
let message = String(e?.message || e)
|
|
1372
1393
|
if (/unsupported_country_region_territory|\b403\b/.test(message)) {
|
|
@@ -1503,20 +1524,23 @@ async function runPi(opts: {
|
|
|
1503
1524
|
appendSystemPrompt?: string
|
|
1504
1525
|
cwd?: string // agent working dir: the knowledge base, so read/grep/find default there
|
|
1505
1526
|
}): Promise<string> {
|
|
1506
|
-
// No --provider/--model: pi's own configuration picks the model and credentials.
|
|
1507
1527
|
const args = [
|
|
1508
1528
|
'-p',
|
|
1509
1529
|
'--mode', 'text',
|
|
1510
1530
|
'--no-extensions', '--no-skills', '--no-context-files', '--no-session', '--no-prompt-templates', '--no-themes',
|
|
1511
1531
|
'--system-prompt', opts.systemPrompt,
|
|
1512
1532
|
]
|
|
1533
|
+
// Unset means pi's own default model and provider. There is no second
|
|
1534
|
+
// provider to fall back to either way.
|
|
1535
|
+
const model = piSummaryModel()
|
|
1536
|
+
if (model) args.push('--model', model)
|
|
1513
1537
|
if (opts.thinking) args.push('--thinking', opts.thinking)
|
|
1514
1538
|
if (opts.tools && opts.tools.trim()) args.push('--tools', opts.tools.trim())
|
|
1515
1539
|
else args.push('--no-tools')
|
|
1516
1540
|
if (opts.appendSystemPrompt) args.push('--append-system-prompt', opts.appendSystemPrompt)
|
|
1517
1541
|
return new Promise<string>((resolve, reject) => {
|
|
1518
1542
|
const inv = piInvocation(args)
|
|
1519
|
-
const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true })
|
|
1543
|
+
const child = spawn(inv.bin, inv.args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, windowsHide: true, env: { ...process.env, ...childProxyEnv } })
|
|
1520
1544
|
let stdout = '', stderr = ''
|
|
1521
1545
|
const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null
|
|
1522
1546
|
child.stdout.on('data', d => stdout += String(d))
|
|
@@ -1562,6 +1586,12 @@ async function chatCompleteViaPi(opts: Parameters<typeof runPi>[0]): Promise<str
|
|
|
1562
1586
|
}
|
|
1563
1587
|
}
|
|
1564
1588
|
|
|
1589
|
+
// pi's --model accepts "provider/id" (e.g. openai-codex/gpt-5.6-sol), so this one
|
|
1590
|
+
// setting pins both. Empty/unset = whatever pi is configured to use.
|
|
1591
|
+
function piSummaryModel(): string {
|
|
1592
|
+
return (process.env.VOICENOTE_PI_MODEL || '').trim()
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1565
1595
|
function piThinkingLevel(): string {
|
|
1566
1596
|
return process.env.VOICENOTE_PI_THINKING || 'high'
|
|
1567
1597
|
}
|
|
@@ -1758,7 +1788,7 @@ async function processRecording(config: Config, rec: Recording, opts: any): Prom
|
|
|
1758
1788
|
|
|
1759
1789
|
let summaryError: any = null
|
|
1760
1790
|
if (needsNotes) {
|
|
1761
|
-
progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes',
|
|
1791
|
+
progressStep(nextStep(), totalSteps, 'Generate integrated semantic notes', `via pi, model=${piSummaryModel() || "pi's own default"}`)
|
|
1762
1792
|
try {
|
|
1763
1793
|
meta = await withHeartbeat('generate integrated semantic notes', () => summarizeTranscript(config, transcript, rec, files.audio), 60)
|
|
1764
1794
|
} catch (e: any) {
|
|
@@ -2688,8 +2718,8 @@ async function collectDoctor() {
|
|
|
2688
2718
|
: { configured: false as const },
|
|
2689
2719
|
// Provider/model/credentials are pi's own configuration; `pi.available` is
|
|
2690
2720
|
// all we can honestly report about whether a summary can run.
|
|
2691
|
-
summary: { backend: 'pi', thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
|
|
2692
|
-
pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(
|
|
2721
|
+
summary: { backend: 'pi', model: piSummaryModel() || null, thinking: piThinkingLevel(), tools: tools || null, contextDir: tools ? summaryContextDir(config) : null },
|
|
2722
|
+
pi: { bin: piCodexBin(), version: piCheck.code === 0 ? (piCheck.stdout.trim() || piCheck.stderr.trim() || null) : null, available: piCheck.code === 0, auth: existsSync(piAuthPath()), authPath: piAuthPath() },
|
|
2693
2723
|
// Outbound proxy for HTTPS endpoints (updater/GitHub): honor the standard
|
|
2694
2724
|
// env chain, not just lowercase http_proxy — an https_proxy-only setup must
|
|
2695
2725
|
// still route the updater.
|
|
@@ -2755,15 +2785,15 @@ async function doctor(opts: { json?: boolean } = {}): Promise<void> {
|
|
|
2755
2785
|
} else {
|
|
2756
2786
|
console.log(`volcano=not configured`)
|
|
2757
2787
|
}
|
|
2758
|
-
console.log(`summaryBackend=${s.summary.backend}
|
|
2759
|
-
console.log(`pi.bin=${s.pi.bin}`)
|
|
2788
|
+
console.log(`summaryBackend=${s.summary.backend}`)
|
|
2789
|
+
console.log(`pi.bin=${s.pi.bin} model=${s.summary.model || "<pi's own default>"}`)
|
|
2760
2790
|
console.log(`pi.thinking=${s.summary.thinking}`)
|
|
2761
2791
|
console.log(`pi.summaryTools=${s.summary.tools || '<disabled>'}`)
|
|
2762
2792
|
if (s.summary.contextDir) console.log(`pi.contextDir=${s.summary.contextDir} (summary agent cwd + read/grep cross-reference root)`)
|
|
2763
2793
|
console.log(`pi.version=${s.pi.version || 'missing'}`)
|
|
2764
2794
|
// Neutral fact, not an instruction: an API-key user has no auth.json and needs
|
|
2765
2795
|
// nothing fixed.
|
|
2766
|
-
console.log(`pi.auth=${s.pi.auth ? '
|
|
2796
|
+
console.log(`pi.auth=${s.pi.authPath} ${s.pi.auth ? '(present)' : '(missing — fine if a provider API key is set)'}`)
|
|
2767
2797
|
console.log(`defaultMode=notes`)
|
|
2768
2798
|
console.log(`proxy=${s.proxy.url || '<unset>'}`)
|
|
2769
2799
|
console.log(`speakers.self=${s.identity.self || '<unset>'}`)
|