{"_id":"@andyfooblah/voice-common","_rev":"3-62d5325762fd6a3c994d69c8f623bccc","name":"@andyfooblah/voice-common","dist-tags":{"latest":"0.15.0"},"versions":{"0.14.0":{"name":"@andyfooblah/voice-common","version":"0.14.0","_id":"@andyfooblah/voice-common@0.14.0","maintainers":[{"name":"andyfooblah","email":"andrew.brook@fooblah.org"}],"dist":{"shasum":"636a5989e8684df048249f1b2c455bd4b7549424","tarball":"https://registry.npmjs.org/@andyfooblah/voice-common/-/voice-common-0.14.0.tgz","fileCount":17,"integrity":"sha512-c04bs49fNFsEpb/Y+Gnl5sds8AMZdWl8Pyi2b9I34awv5oJg44QY2v55snxDvtJaEPixGST5L9bGck37CgJTJg==","signatures":[{"sig":"MEQCIE7sCrbRe1HmbrXi1iVjNbW9AYBS/acnjjo5E/WeVNGHAiADa66xkTNLR+CuHHOzrOOG7fIXi48QeYPRSS+92E+5og==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":84850},"main":"./dist/index.js","type":"module","types":"./dist/lib.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/lib.d.ts","import":"./dist/index.js"},"./testing":{"types":"./dist/testing/index.d.ts","import":"./dist/testing.js"}},"gitHead":"e60d67d19912654cc9754e7b36ad2fff7063f543","scripts":{"dev":"vite","lint":"eslint src --ext .ts,.tsx","test":"vitest run","build":"vite build && node scripts/check-bundle-for-secrets.mjs","deploy":"vite build && firebase deploy","preview":"vite preview","build:lib":"vite build --config vite.lib.config.ts && node scripts/check-bundle-for-secrets.mjs","test:watch":"vitest","check:bundle":"node scripts/check-bundle-for-secrets.mjs","deploy:preview":"vite build && firebase hosting:channel:deploy preview"},"_npmUser":{"name":"andyfooblah","email":"andrew.brook@fooblah.org"},"_npmVersion":"11.11.0","description":"Reusable framework for building voice AI web applications powered by Google Gemini Live and Firebase.","directories":{},"_nodeVersion":"24.14.1","publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"vite":"^6.2.0","jsdom":"^28.1.0","react":"^19.2.0","eslint":"^9.39.4","vitest":"^4.1.5","globals":"^17.4.0","firebase":"^12.9.0","react-dom":"^19.2.0","@eslint/js":"^9.39.4","typescript":"~5.8.2","@types/node":"^22.14.0","tailwindcss":"^4.2.0","@types/react":"^19.2.14","@google/genai":"^2.0.1","vite-plugin-dts":"^5.0.1","@types/react-dom":"^19.2.3","react-router-dom":"^7.13.0","@tailwindcss/vite":"^4.2.0","@vitest/coverage-v8":"^4.1.5","@testing-library/dom":"^10.4.1","@vitejs/plugin-react":"^5.0.0","@testing-library/react":"^16.3.2","@testing-library/jest-dom":"^6.9.1","@typescript-eslint/parser":"^8.58.0","eslint-plugin-react-hooks":"^7.0.1","@testing-library/user-event":"^14.6.1","@andyfooblah/knowledge-common":"^1.2.0","@typescript-eslint/eslint-plugin":"^8.58.0"},"peerDependencies":{"react":"^19.0.0","firebase":"^12.0.0","react-dom":"^19.0.0","@google/genai":"^2.0.0"},"_npmOperationalInternal":{"tmp":"tmp/voice-common_0.14.0_1783702880816_0.8588745178805268","host":"s3://npm-registry-packages-npm-production"}},"0.14.1":{"name":"@andyfooblah/voice-common","version":"0.14.1","license":"Apache-2.0","_id":"@andyfooblah/voice-common@0.14.1","maintainers":[{"name":"andyfooblah","email":"andrew.brook@fooblah.org"}],"homepage":"https://github.com/AndyFooBlah/VoiceCommon#readme","bugs":{"url":"https://github.com/AndyFooBlah/VoiceCommon/issues"},"dist":{"shasum":"f5c8fc36f523551dc5c361a4d40661e30cb3a4fa","tarball":"https://registry.npmjs.org/@andyfooblah/voice-common/-/voice-common-0.14.1.tgz","fileCount":17,"integrity":"sha512-3lhXS8gtGVQ86nXjM19DsfpOa8Zz3NW0ggksFL4+lUWXwYozOgEx8YrCqRuTPBp5R5QcRJPz2gs8yEQ6fTmGGA==","signatures":[{"sig":"MEUCIF5fvyXJk0dzyG4eKvIsGU5zQpQxMWLi7el4LJs9c/qRAiEAtPp93QOxk4w349fYqxhurxoRFnBUNyaZ4b6giyu7Tds=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEYCIQD2U8VRXDcBGWSG39CjjEV43zq+fFMRhP2Z7A1UmnuPLAIhALUqb2WucKYzYKqMTMgG0f7lFFHKRw/WGXw9OSUjZL4K","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@andyfooblah%2fvoice-common@0.14.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":87329},"main":"./dist/index.js","type":"module","types":"./dist/lib.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/lib.d.ts","import":"./dist/index.js"},"./testing":{"types":"./dist/testing/index.d.ts","import":"./dist/testing.js"}},"gitHead":"c0ed1cb4b190c71605b0612281893de422c2008b","scripts":{"dev":"vite","lint":"eslint src --ext .ts,.tsx","test":"vitest run","build":"vite build && node scripts/check-bundle-for-secrets.mjs","deploy":"vite build && firebase deploy","preview":"vite preview","build:lib":"vite build --config vite.lib.config.ts && node scripts/check-bundle-for-secrets.mjs","test:watch":"vitest","check:bundle":"node scripts/check-bundle-for-secrets.mjs","deploy:preview":"vite build && firebase hosting:channel:deploy preview"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:561568d4-bd65-4ee0-9622-84079f15a396"}},"repository":{"url":"git+https://github.com/AndyFooBlah/VoiceCommon.git","type":"git"},"_npmVersion":"11.19.1","description":"Reusable framework for building voice AI web applications powered by Google Gemini Live and Firebase.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"vite":"^6.2.0","jsdom":"^28.1.0","react":"^19.2.0","eslint":"^9.39.4","vitest":"^4.1.5","globals":"^17.4.0","firebase":"^12.9.0","react-dom":"^19.2.0","@eslint/js":"^9.39.4","typescript":"~5.8.2","@types/node":"^22.14.0","tailwindcss":"^4.2.0","@types/react":"^19.2.14","@google/genai":"^2.0.1","vite-plugin-dts":"^5.0.1","@types/react-dom":"^19.2.3","react-router-dom":"^7.13.0","@tailwindcss/vite":"^4.2.0","@vitest/coverage-v8":"^4.1.5","@testing-library/dom":"^10.4.1","@vitejs/plugin-react":"^5.0.0","@testing-library/react":"^16.3.2","@testing-library/jest-dom":"^6.9.1","@typescript-eslint/parser":"^8.58.0","eslint-plugin-react-hooks":"^7.0.1","@testing-library/user-event":"^14.6.1","@andyfooblah/knowledge-common":"^1.2.0","@typescript-eslint/eslint-plugin":"^8.58.0"},"peerDependencies":{"react":"^19.0.0","firebase":"^12.0.0","react-dom":"^19.0.0","@google/genai":"^2.0.0"},"_npmOperationalInternal":{"tmp":"tmp/voice-common_0.14.1_1789104730762_0.7665976865649606","host":"s3://npm-registry-packages-npm-production"}},"0.15.0":{"_id":"@andyfooblah/voice-common@0.15.0","bugs":{"url":"https://github.com/AndyFooBlah/VoiceCommon/issues"},"dist":{"shasum":"7524ab589fe5cdc680e6e97cbeb711631c2324cb","tarball":"https://registry.npmjs.org/@andyfooblah/voice-common/-/voice-common-0.15.0.tgz","fileCount":17,"integrity":"sha512-CXPkGFsFgduP54id6Avf4M2G4Gjafjbr0CD3TJ8ovACGxFF1dImNfA2plXK74GA7Gv796qDfOOPqqCZQ7DnR1Q==","signatures":[{"sig":"MEUCIQDj6MjIDzk6PnXEHSj5mmajh4OMBFL1BCeYL8UqFK30awIgT4BfSXLY1ces+ahypRUGWAiDtq6srESCQOW0ZMkEV/c=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIBIdzkNff13pMEOr/VlVbmb2275+61qV1bGBPZ1LjpvlAiAxGQJJZ2JHXcxzhjFhsAzVfZa3SML08xp6pbzmI3bL5g=="}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@andyfooblah%2fvoice-common@0.15.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":88659},"main":"./dist/index.js","name":"@andyfooblah/voice-common","type":"module","types":"./dist/lib.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/lib.d.ts","import":"./dist/index.js"},"./testing":{"types":"./dist/testing/index.d.ts","import":"./dist/testing.js"}},"gitHead":"33bd06b4f626a4cec6c14403e35622fd0d467b2d","license":"Apache-2.0","scripts":{"dev":"vite","lint":"eslint src --ext .ts,.tsx","test":"vitest run","build":"vite build && node scripts/check-bundle-for-secrets.mjs","deploy":"vite build && firebase deploy","preview":"vite preview","build:lib":"vite build --config vite.lib.config.ts && node scripts/check-bundle-for-secrets.mjs","test:watch":"vitest","check:bundle":"node scripts/check-bundle-for-secrets.mjs","deploy:preview":"vite build && firebase hosting:channel:deploy preview"},"version":"0.15.0","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:561568d4-bd65-4ee0-9622-84079f15a396"}},"homepage":"https://github.com/AndyFooBlah/VoiceCommon#readme","repository":{"url":"git+https://github.com/AndyFooBlah/VoiceCommon.git","type":"git"},"_npmVersion":"11.19.1","description":"Reusable framework for building voice AI web applications powered by Google Gemini Live and Firebase.","directories":{},"maintainers":[{"name":"andyfooblah","email":"andrew.brook@fooblah.org"}],"_nodeVersion":"22.23.2","publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"vite":"^6.2.0","jsdom":"^28.1.0","react":"^19.2.0","eslint":"^9.39.4","vitest":"^5.0.1","globals":"^17.4.0","firebase":"^12.9.0","react-dom":"^19.2.0","@eslint/js":"^9.39.4","typescript":"~5.8.2","@types/node":"^22.14.0","tailwindcss":"^4.2.0","@types/react":"^19.2.14","@google/genai":"^2.0.1","vite-plugin-dts":"^5.0.1","@types/react-dom":"^19.2.3","react-router-dom":"^7.13.0","@tailwindcss/vite":"^4.2.0","@vitest/coverage-v8":"^5.0.1","@testing-library/dom":"^10.4.1","@vitejs/plugin-react":"^5.0.0","@testing-library/react":"^16.3.2","@testing-library/jest-dom":"^6.9.1","@typescript-eslint/parser":"^8.58.0","eslint-plugin-react-hooks":"^7.0.1","@testing-library/user-event":"^14.6.1","@andyfooblah/knowledge-common":"^1.2.0","@typescript-eslint/eslint-plugin":"^8.58.0"},"peerDependencies":{"react":"^19.0.0","firebase":"^12.0.0","react-dom":"^19.0.0","@google/genai":"^2.0.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/voice-common_0.15.0_1789773756951_0.3769643079350178"}}},"time":{"created":"2026-07-10T17:01:20.587Z","modified":"2026-09-18T23:22:37.386Z","0.14.0":"2026-07-10T17:01:20.980Z","0.14.1":"2026-09-11T05:32:10.841Z","0.15.0":"2026-09-18T23:22:37.048Z"},"bugs":{"url":"https://github.com/AndyFooBlah/VoiceCommon/issues"},"license":"Apache-2.0","homepage":"https://github.com/AndyFooBlah/VoiceCommon#readme","repository":{"url":"git+https://github.com/AndyFooBlah/VoiceCommon.git","type":"git"},"description":"Reusable framework for building voice AI web applications powered by Google Gemini Live and Firebase.","maintainers":[{"name":"andyfooblah","email":"andrew.brook@fooblah.org"}],"readme":"# VoiceCommon\n\n`@andyfooblah/voice-common` — a reusable framework for building voice AI web applications powered by Google Gemini Live and Firebase. See [CHANGELOG.md](CHANGELOG.md) for the current version and release history.\n\n> **Origin:** VoiceCommon was started as a way to extract reusable common functionality from [LegacyBot](https://github.com/AndyFooBlah/LegacyBot), a voice-first life story preservation app. The patterns for real-time voice sessions, transcript archival, audio recording, and AI tool integrations have been generalized here into a clean framework that any voice AI app can build on.\n\nKnowledge tools (weather, maps, jokes, Wikipedia, date/time) are provided separately by [`@andyfooblah/knowledge-common`](https://github.com/AndyFooBlah/KnowledgeCommon).\n\n---\n\n## What VoiceCommon provides\n\n- **Gemini Live integration** — real-time bidirectional voice sessions with Google's Gemini Live API, including PCM audio streaming, bot audio playback scheduling, and connection lifecycle management\n- **Firebase authentication** — Google OAuth and email/password sign-in, with user profile creation in Firestore\n- **Session archival** — automatic recording of mixed user+bot audio to Cloud Storage (WebM/Opus), real-time transcript sync to Firestore\n- **Session resumption with continuous recording** — on unexpected disconnect (the Live API's ~10-min connection resets, transient errors), the session is resumed via Gemini session-resumption handles with full conversation context (no re-greeting). The archival recorder is never restarted, so the recording stays one continuous file. If resumption fails 3 times in a row — or the recorder itself fails — the session halts and finalizes the recording captured so far rather than continuing unrecorded (see `design.md` §3.4)\n- **Repetition detection** — detects near-duplicate bot turns and sends a recovery prompt to break the loop\n- **Example application** — a working 3-page app (login, session history, new session) demonstrating the full framework\n\n---\n\n## Example app pages\n\n| Route | Page | Description |\n|-------|------|-------------|\n| `/` | → `/sessions` | Redirect |\n| `/sessions` | Session History | List of past voice sessions with status and duration |\n| `/sessions/new` | New Session | Live voice session with real-time transcript and waveform |\n| `/sessions/:id` | Transcript Viewer | Past session transcript and audio playback |\n\n---\n\n## Getting started\n\n### Prerequisites\n\n- Node.js 22+\n- A Firebase project (Firestore, Authentication, Cloud Storage enabled)\n- A Google Gemini API key from [Google AI Studio](https://aistudio.google.com) — held **server-side only**, behind your token broker (see below)\n- (Optional) A Google Maps API key for weather and location tools\n\n### 1. Clone and install\n\n```bash\ngit clone https://github.com/AndyFooBlah/VoiceCommon.git voicecommon\ncd voicecommon\nnpm install\n```\n\nAll dependencies — including [`@andyfooblah/knowledge-common`](https://github.com/AndyFooBlah/KnowledgeCommon) — resolve from npmjs.org; no sibling checkouts or tokens required.\n\n> **Iterating on KnowledgeCommon locally?** Check it out anywhere, run `npm link` in it, then `npm link @andyfooblah/knowledge-common` here to use your local copy instead of the published version.\n\n### 2. Configure environment\n\n```bash\ncp .env.example .env\n# Edit .env with your Firebase web-app config (apiKey, projectId, …) only.\n# There is NO Gemini key in .env: Gemini access goes through the `tokenProvider`\n# you pass to useSession — a server-side broker that mints short-lived Gemini\n# Live tokens. `.env` and `.env.*` are gitignored (only .env.example is tracked).\n```\n\n### 3. Set up Firebase\n\n```bash\ncp .firebaserc.example .firebaserc\n# Edit .firebaserc with your Firebase project ID\nfirebase deploy --only firestore:rules,storage\n```\n\n### 4. Run the dev server\n\n```bash\nnpm run dev\n```\n\n---\n\n## Building your own app on VoiceCommon\n\n### Installation\n\n```bash\nnpm install @andyfooblah/voice-common\n```\n\n### Initialization\n\nCall `initializeVoiceCommon` once at app startup before mounting React:\n\n```typescript\nimport { initializeVoiceCommon } from '@andyfooblah/voice-common';\n\ninitializeVoiceCommon({\n  firebase: {\n    apiKey: import.meta.env.VITE_FIREBASE_API_KEY,\n    authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,\n    projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,\n    storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,\n    messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,\n    appId: import.meta.env.VITE_FIREBASE_APP_ID,\n  },\n  // Required. VoiceCommon never accepts a long-lived Gemini API key —\n  // the long-lived key would end up in your bundle, which is the exact\n  // failure mode this library is designed to prevent. Implement\n  // tokenProvider as a thin wrapper around your own server-side broker\n  // (e.g. a Firebase Cloud Function that holds GEMINI_API_KEY in Secret\n  // Manager and returns a single-use ephemeral token via Gemini's\n  // authTokens.create API). It's invoked once per Live session opening.\n  tokenProvider: async () => {\n    const result = await myMintGeminiLiveTokenCallable();\n    return result.data; // { token: string, expireTime: string }\n  },\n});\n```\n\n> **Note:** the `geminiApiKey?: string` field that earlier versions\n> accepted was deleted in 0.6.0. Even as a \"local dev\" fallback it\n> caused keys to ship in consumers' bundles by accident — the same\n> incident pattern that motivated the broker design in the first\n> place. See `design.md` §5 for the full rationale and `CLAUDE.md`\n> for the type-system + ESLint + post-build-scan guards that enforce\n> this at compile and build time.\n\n### Custom system instruction\n\nReplace the default assistant with your own by calling `buildSessionInstruction` with your own `assistantName` and `appContext`:\n\n```typescript\nimport { buildSessionInstruction } from '@andyfooblah/voice-common';\n\nconst instruction = buildSessionInstruction({\n  assistantName: 'Nova',\n  appContext: 'You are a customer service agent for Acme Corp...',\n  currentDateTime: new Date().toLocaleString(),\n});\n```\n\nOr skip `buildSessionInstruction` entirely and pass your own string directly to `useSession`.\n\n### Custom tools\n\nAdd tools alongside knowledge tools, or replace them:\n\n```typescript\nimport { useSession } from '@andyfooblah/voice-common';\nimport { allKnowledgeTools } from '@andyfooblah/knowledge-common';\nimport type { FunctionDeclaration } from '@google/genai';\n\nconst myTool: FunctionDeclaration = {\n  name: 'lookupOrder',\n  description: 'Look up a customer order by order number.',\n  parameters: { ... },\n};\n\nconst { startSession, stopSession, messages } = useSession({\n  userId: user.uid,\n  systemInstruction: myInstruction,\n  tools: [...allKnowledgeTools, myTool],\n  onToolCall: async (name, args) => {\n    if (name === 'lookupOrder') return lookupOrder(args.orderNumber);\n    return 'Unknown tool.';\n  },\n});\n```\n\nNote: the `endSession` tool is always injected automatically by VoiceCommon — do not declare it yourself.\n\n### Post-session processing\n\nThe `onSessionCompleted` Cloud Function in `functions/src/index.ts` fires whenever a session transitions to `completed`. Add your own server-side logic there — transcript analysis, notifications, summaries, webhooks, etc.\n\n---\n\n## `useSession` API reference\n\n```typescript\nimport { useSession } from '@andyfooblah/voice-common';\n```\n\n### `UseSessionOptions`\n\n| Option | Type | Required | Description |\n|--------|------|----------|-------------|\n| `userId` | `string` | Yes | Firebase Auth UID of the session owner |\n| `systemInstruction` | `string` | Yes | Full system instruction string for Gemini |\n| `tools` | `FunctionDeclaration[]` | No | Tool declarations to register with Gemini |\n| `onToolCall` | `(name, args) => Promise<string>` | No | Called when Gemini invokes a tool; return value is sent as the tool result |\n| `onSessionEndRequest` | `() => void` | No | Called when the bot invokes the built-in `endSession` tool |\n| `onSessionEnd` | `() => void` | No | Called after the session is fully finalized (audio uploaded, Firestore updated). Use for post-session analysis or state cleanup |\n| `onBotSpeaking` | `(speaking: boolean) => void` | No | Called when bot audio starts or stops (for UI feedback) |\n| `autoGreetText` | `string` | No | Text sent via `sendRealtimeInput` immediately after connecting so the bot takes the first turn. Use bracket notation for system cues, e.g. `\"[Session started. Please greet the family.]\"` |\n| `speechConfig` | `SpeechConfig` | No | Voice configuration for the Gemini model (e.g. prebuilt voice name). Passed directly to the Gemini Live `speechConfig` field |\n| `endOfSpeechSilenceMs` | `number` | No | How long a pause the user may take before their turn ends. In server-VAD mode maps to `automaticActivityDetection.silenceDurationMs`; in manual mode it's the client-side end-of-turn silence. |\n| `endOfSpeechSensitivity` | `'HIGH' \\| 'LOW'` | No | Server-VAD end-of-speech eagerness. `'HIGH'` (default) ends turns quickly; `'LOW'` waits through longer pauses. Ignored in manual mode. |\n| `manualTurnControl` | `boolean` | No | When true, disable server VAD and drive turn boundaries with a client-side energy VAD (patient, robust to eager end-of-turn on native-audio models). Default false. |\n| `sessionsCollection` | `string` | No | Firestore collection path for session documents. Default: `'sessions'`. For nested/scoped sessions, use a path like `'families/{familyId}/dossiers/{dossierId}/sessions'` |\n\n### `UseSessionReturn`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `messages` | `Message[]` | Live transcript messages for the current session |\n| `connectionStatus` | `ConnectionStatus` | `DISCONNECTED \\| CONNECTING \\| CONNECTED \\| ERROR` |\n| `startSession(overrideInstruction?, overrideAutoGreetText?)` | `() => Promise<void>` | Start a new session. Optional overrides bypass stale-closure issues when instruction or greet text is built just before calling |\n| `stopSession` | `() => Promise<void>` | Stop the session, upload audio, finalize Firestore document, call `onSessionEnd` |\n| `isRecording` | `boolean` | True while a session is active |\n| `sessionId` | `string \\| null` | Firestore session document ID for the current session |\n| `error` | `string \\| null` | Human-readable error message if status is `ERROR` |\n\n### `sessionsCollection` example\n\nAn app with family-scoped data might use nested session paths:\n\n```typescript\nuseSession({\n  userId: user.uid,\n  systemInstruction,\n  sessionsCollection: `families/${familyId}/dossiers/${dossierId}/sessions`,\n  // ...\n});\n```\n\nAll VoiceCommon storage calls (create, finalize, transcript sync) use this prefix automatically.\n\n---\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md).\n\n---\n\n## Releasing\n\nReleases are published to npmjs.org by `.github/workflows/publish.yml` using\n[npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) —\nno npm token is stored in the repo or in GitHub secrets, and every publish\ncarries provenance.\n\n**One-time setup on npmjs.com (package owner only):**\n\n1. Sign in to <https://www.npmjs.com/> and open\n   <https://www.npmjs.com/package/@andyfooblah/voice-common/access>\n   (Package → *Settings* → *Trusted Publishing*).\n2. Under *Trusted Publisher*, choose **GitHub Actions** and enter:\n   - Organization or user: `AndyFooBlah`\n   - Repository: `VoiceCommon`\n   - Workflow filename: `publish.yml`\n   - Environment name: *(leave blank)*\n3. Save. From then on any run of `publish.yml` from this repo can publish;\n   nothing else can (no token exists to leak).\n\nIf the very first tagged run failed with `ENEEDAUTH`/`E404` before this was\nconfigured, re-run it from the Actions tab after step 3 (or re-push the tag).\n\n**Cutting a release:**\n\n```bash\n# 1. bump \"version\" in package.json, add a CHANGELOG.md entry, commit + push\n# 2. tag and push the tag — the workflow tests, builds and publishes\ngit tag -a v0.14.1 -m \"v0.14.1\"\ngit push origin v0.14.1\n# 3. confirm\nnpm view @andyfooblah/voice-common version\n```\n\nEvery released version has a matching annotated `vX.Y.Z` tag (back-filled for\n0.5.0–0.14.0). Creating a GitHub Release for a tag also triggers the workflow\n(`release: published`); npm rejects a duplicate version, so re-runs are safe.\n\n---\n\n## Project structure\n\n```\nsrc/\n├── services/\n│   ├── firebase.ts          # Firebase app initialization\n│   ├── gemini.ts            # Gemini Live session instruction builder + tool registry\n│   ├── storage.ts           # Firestore + GCS session/transcript persistence\n│   └── audioUtils.ts        # PCM encoding, decoding, resampling\n├── hooks/\n│   ├── useAuth.ts           # Firebase auth state + sign-in/sign-out\n│   ├── useSession.ts        # Live session lifecycle (start, stream, stop, archive)\n│   └── useAudioMixer.ts     # Microphone + bot audio mixing for archival\n├── components/\n│   ├── auth/\n│   │   └── LoginScreen.tsx  # Login page (Google + email/password)\n│   ├── session/\n│   │   ├── SessionView.tsx  # New session page\n│   │   ├── TranscriptFeed.tsx  # Real-time transcript display\n│   │   └── Visualizer.tsx   # Animated waveform\n│   ├── history/\n│   │   ├── SessionList.tsx  # Session history page\n│   │   ├── TranscriptViewer.tsx  # Past session detail\n│   │   └── AudioPlayer.tsx  # Audio playback with seek bar\n│   └── shared/\n│       ├── Layout.tsx       # App shell with nav and auth guard\n│       ├── ErrorBoundary.tsx\n│       └── Logo.tsx\n├── types.ts                 # Core TypeScript interfaces\n└── App.tsx                  # Router\n\nfunctions/\n└── src/\n    └── index.ts             # Cloud Functions (onSessionCompleted hook)\n\npublic/\n└── pcm-processor.js         # AudioWorklet for low-latency PCM streaming\n```\n\n---\n\n## Firestore data model\n\n#### `users/{uid}`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `email` | `string` | User's email address |\n| `displayName` | `string` | Display name from auth provider |\n| `createdAt` | `Timestamp` | Account creation time |\n| `timezone` | `string?` | IANA timezone (e.g. `\"America/Los_Angeles\"`), set from browser |\n\n#### `sessions/{sessionId}`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `userId` | `string` | Firebase UID of the session owner |\n| `startTime` | `Timestamp` | When the session started |\n| `endTime` | `Timestamp \\| null` | When the session ended; null while active |\n| `audioUrl` | `string` | GCS download URL for the archived audio (empty until upload completes) |\n| `status` | `\"active\" \\| \"completed\" \\| \"interrupted\"` | Session lifecycle state |\n| `durationSeconds` | `number` | Total session duration |\n\n#### `sessions/{sessionId}/transcript/entries`\n\nA single document with an `entries` array, written in full on each sync:\n\n```\n{ entries: TranscriptEntry[] }\n```\n\nEach `TranscriptEntry`:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `role` | `\"user\" \\| \"bot\" \\| \"tool\"` | Who produced this turn |\n| `text` | `string` | Transcript text, or `[toolName]` for tool turns |\n| `timestamp` | `Timestamp` | When this turn was recorded |\n| `messageIndex` | `number?` | 0-based position in the session |\n| `toolName` | `string?` | Present when `role === \"tool\"` |\n| `toolArgs` | `Record<string, unknown>?` | Arguments passed to the tool |\n| `toolResult` | `string?` | Truncated tool result (≤ 500 chars) |\n\n## Cloud Storage layout\n\n```\nsessions/{userId}/{sessionId}.webm    # Session audio (mixed, WebM/Opus 128kbps)\n```\n\n---\n\n## Tech stack\n\n| Layer | Technology |\n|-------|-----------|\n| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |\n| AI | Google Gemini Live API (`@google/genai`) |\n| Auth | Firebase Authentication |\n| Database | Cloud Firestore |\n| Storage | Firebase Cloud Storage |\n| Backend | Firebase Cloud Functions v2 (Node.js 22) |\n| Testing | Vitest, React Testing Library |\n| CI | GitHub Actions |\n\n---\n\n## License\n\nApache 2.0 — see [LICENSE](LICENSE).\n","readmeFilename":"README.md"}