LLM-backed memory + cue reasoning (server-side, optional)#4
Draft
wdwd720 wants to merge 1 commit into
Draft
Conversation
Adds an OpenAI-compatible LLM brain on the server with strict structured
output, a deterministic safety guard, and a mock provider for tests.
The whole app still runs without a key — the LLM is purely additive.
Server-side LLM layer (server/src/llm/):
- llmConfig.ts Loads .env once. Read-only config object with
enabled/apiKey/baseUrl/model/timeout/maxInput/
storeRawTranscripts. maskApiKey() never logs the
full key.
- llmTypes.ts LlmProvider, LlmProviderStatus, MemoryExtraction
and CueReasoning input/output types.
- llmSchemas.ts JSON schemas for the strict response_format and
hand-rolled validators that reject unknown kinds,
bad priorities, missing fields, etc.
- llmPrompts.ts System + user prompts for memory extraction and
cue reasoning, with explicit glasses-first
constraints (HUD <=2 lines, no certainty about
hazards, no transcript on HUD, no medical /
location-bias claims).
- llmErrors.ts Compact LlmError with kind=config/network/
timeout/schema/provider, never logs the full
request body.
- openAiCompatibleProvider.ts Single fetch() to /chat/completions with
response_format=json_schema, strict timeout,
response is JSON.parsed and re-validated.
- mockLlmProvider.ts Deterministic mock used by tests. Reuses the
rule-based extractor, then applies cue reasoning
rules so /api/decide can be exercised without
network.
- llmProvider.ts Factory: returns OpenAiCompatibleProvider when
HEARER_LLM_ENABLED=true and a key is present;
otherwise a DisabledLlmProvider whose status
reports 'disabled' / 'key missing'.
Server services:
- services/llmMemoryExtractor.ts Wraps the provider, validates the
response, persists items the model marked
save=true (routine, commitment, important_item,
known_person, location_rule). Falls back to the
existing rule-based extractor on disabled / error
/ schema failure. Returns mode/extracted/persisted
/summary/confidence/fallbackUsed/reason.
- services/llmCueReasoner.ts Builds the CueReasoningInput from memory
summary + live context + recent feedback, asks
the provider for a cue, runs the safety guard,
and returns either a Hearer Cue or a structured
rejection.
- services/safetyGuard.ts validateAndRejectUnsafeCue + sanitizeTranscript
ForStorage + shouldStoreRawTranscript. Hard-deny
patterns: 'stove is on', 'emergency', 'danger',
'you forgot', diagnoses, location-bias.
direction-only-when-known check; max 2 lines / 80
chars; transcript-leakage guard.
Server endpoints:
- GET /api/llm/status provider/configured/enabled/model/reason.
- POST /api/memory/extract Returns mode='llm' or 'rule_based',
fallbackUsed, persisted records, summary.
- POST /api/decide Stable detection + context override -> cue.
Persists the AudioDetection and CueRecord.
Returns mode='llm' / 'llm_rejected_fallback'
/ 'rule_based' depending on what happened.
- buildApp() factory in server/src/app.ts so tests can inject storage and
a MockLlmProvider; production server.ts is now a tiny entry-point.
Frontend wiring:
- src/api/hearerApi.ts adds llmStatus() and decide() with a 15 s timeout.
- ApiMemoryClient exposes llmStatus() and surfaces extract().mode through
the MemoryClient interface; LocalMemoryClient marks fallback responses
as mode='rule_based'.
- AudioController.setServerBrain() optional hook that REPLACES the local
decision when /api/decide returns a cue. Local decision is still emitted
first to keep the dev event log responsive; server result re-emits when
it lands.
- App.tsx probes /api/llm/status on mount, installs the server-brain hook
when Memory API is connected, and exposes a 'Server brain' toggle.
- Header chip now reads 'LLM: connected · gpt-4.1-mini' / 'LLM: mock' /
'LLM: key missing' / 'LLM: disabled fallback'.
- MemoryCommandBox shows 'Saved N items via LLM extractor.' or
'rule-based extractor' so the demo is honest about which path ran.
Tests (Vitest, all 65/65 passing across 18 suites):
- server/src/__tests__/llmStatus.test.ts — disabled status when no
key; /api/memory/extract uses rule_based fallback.
- server/src/__tests__/llmExtract.test.ts — mock LLM extracts the
cooking routine (killer demo) and the Jason commitment.
- server/src/__tests__/decide.test.ts — deterministic fallback
when LLM disabled; LLM cue 'Kitchen timer beeping. / Check stove.' when
enabled with cooking routine; safety guard rejects 'Stove is on.';
rejects direction cue when no direction supplied.
- server/src/__tests__/safetyGuard.test.ts — accepts safe cue;
rejects 'stove is on', 'emergency', diagnoses, missing direction,
too long, too many lines.
- server/src/__tests__/llmSchemas.test.ts — accepts well-formed
payloads; rejects unknown kind/priority.
- All 46 prior tests still pass (audio, world-state, hud adapters, etc.).
Privacy / safety honoured:
- API key only on the server. Frontend never sees it.
- Audio is never sent to the LLM; only short text + structured detection
metadata.
- Default HEARER_STORE_RAW_TRANSCRIPTS=false, transcripts sanitised for
phone/email/credit numbers before storage when stored.
- 12 s timeout, 4000-char input cap, both configurable.
- Per-label cooldown on /api/decide so the LLM is not called per frame.
- Hearer never claims sensor-grade certainty: 'Check stove.' not 'Stove is
on.'. Direction only mentioned when supplied. No medical / emergency
/ location-bias language.
Build + tests both pass.
Co-authored-by: wdwd720 <wdwd720@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A real OpenAI-compatible LLM brain on the server, with strict structured output, a deterministic safety guard, and a mock provider for tests. The whole app still runs without a key — the LLM is purely additive.
What the LLM does (and doesn't)
POST /api/memory/extract) turns a sentence like "Usually after dinner I cook, so if you hear beeping remind me to check the stove." into a structuredroutine(trigger description, action label, priority). Falls back to the rule-based extractor on disabled / error / schema failure.POST /api/decide) takes a stable detection + memory + live context and proposes the HUD cue. The safety guard then rejects anything that claims certainty about hazards, mentions direction without evidence, exceeds 2 lines, repeats a transcript, etc.Server layer (
server/src/llm/)llmConfig.tsloads.envonce. Read-onlyLlmConfigwith enabled / apiKey / baseUrl / model / timeout / maxInput / storeRawTranscripts.maskApiKey()ensures the full key never appears in logs.llmTypes.ts,llmErrors.ts,llmSchemas.ts,llmPrompts.tsare the contract surface.openAiCompatibleProvider.tsissues a singlefetch()to/chat/completionswithresponse_format: { type: "json_schema", strict: true }, then re-validates the parsed JSON server-side.mockLlmProvider.tslets tests exercise the same code path without a network. It reuses the rule-based extractor and applies the cue reasoning rules deterministically.llmProvider.tsfactory: real provider whenHEARER_LLM_ENABLED=trueand a key is present; otherwise aDisabledLlmProviderwhose status reports the reason honestly.Server services
services/llmMemoryExtractor.ts— wraps the provider, validates the response, persists items the model markedsave=true(routine / commitment / important_item / known_person / location_rule). Returnsmode/extracted/persisted/summary/confidence/fallbackUsed/reason.services/llmCueReasoner.ts— buildsCueReasoningInputfrom memory summary + live context + recent feedback, asks the provider for a cue, runs the safety guard, returns either a HearerCueor a structured rejection.services/safetyGuard.ts—validateAndRejectUnsafeCue+sanitizeTranscriptForStorage+shouldStoreRawTranscript. Hard-deny patterns:stove is on,emergency,danger,you forgot, diagnoses, location-bias. Direction-only-when-known check. Max 2 lines / 80 chars. Transcript-leakage guard.Endpoints
GET /api/llm/status—{ enabled, configured, provider, model, reason }.POST /api/memory/extract— returnsmode: "llm" | "rule_based",fallbackUsed, persisted records, summary.POST /api/decide— stable detection + context override → cue. Persists theAudioDetectionandCueRecord. Returnsmode: "llm" | "llm_rejected_fallback" | "rule_based".buildApp({ storage, llmProvider })factory inserver/src/app.ts. Tests inject a mock provider; productionserver.tsis now a tiny entry-point.Frontend wiring
src/api/hearerApi.tsaddsllmStatus()anddecide()(15 s timeout).ApiMemoryClientexposesllmStatus().MemoryClient.extract()surfaces the server'smode.LocalMemoryClientreturnsmode: "rule_based".AudioController.setServerBrain()is an optional hook that REPLACES the local decision when/api/decidereturns a cue. The local decision is still emitted first so the dev event log stays responsive; the server result re-emits when it lands.App.tsxprobes/api/llm/statuson mount, installs the server-brain hook when the Memory API is connected, and exposes a Server brain toggle.Tests
npm test→ 65 / 65 passing across 18 suites (was 46 / 13). New ones:server/src/__tests__/llmStatus.test.ts— disabled status with no key;/api/memory/extractusesrule_basedfallback.server/src/__tests__/llmExtract.test.ts— mock LLM extracts the cooking routine (killer demo) and the Jason commitment.server/src/__tests__/decide.test.ts— deterministic fallback when LLM disabled; LLM cue "Kitchen timer beeping. / Check stove." when enabled with cooking routine; safety guard rejects "Stove is on."; rejects a direction cue when no direction supplied.server/src/__tests__/safetyGuard.test.ts— accepts safe cue; rejectsstove is on,emergency, diagnoses, missing-direction cues, too long, too many lines.server/src/__tests__/llmSchemas.test.ts— accepts well-formed payloads; rejects unknown kind / priority.npm run buildsucceeds (~252 kB JS gzipped to ~76 kB).Privacy / safety
HEARER_STORE_RAW_TRANSCRIPTS=false. Stored text is sanitised for phone / email / credit numbers./api/decideso the LLM is not called per frame.How to enable
cp .env.example .env # edit: OPENAI_API_KEY=sk-... HEARER_LLM_ENABLED=true OPENAI_MODEL=gpt-4.1-minicurl http://localhost:8788/api/llm/statusshould now showprovider: "openai_compatible". Save the cooking routine in Teach Hearer, then click Beep ×5 in the audio fallback panel — with Server brain on, the HUD shows "Kitchen timer beeping. / Check stove." (URGENT · PHYSICAL).