Skip to content

LLM-backed memory + cue reasoning (server-side, optional)#4

Draft
wdwd720 wants to merge 1 commit into
cursor/memory-api-dataset-spine-c3fafrom
cursor/llm-memory-and-reasoning-c3fa
Draft

LLM-backed memory + cue reasoning (server-side, optional)#4
wdwd720 wants to merge 1 commit into
cursor/memory-api-dataset-spine-c3fafrom
cursor/llm-memory-and-reasoning-c3fa

Conversation

@wdwd720

@wdwd720 wdwd720 commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Stacked on PR #3 (cursor/memory-api-dataset-spine-c3fa) so this PR's diff is just the LLM layer.

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.

.env (server-only)  →  OpenAiCompatibleProvider  →  /chat/completions (json_schema)
                              ↓
       LlmMemoryExtractor    LlmCueReasoner
                              ↓
                          safetyGuard      → Hearer Cue → HUD
                              ↓
                         deterministic fallback (always available)

What the LLM does (and doesn't)

  • Memory extraction (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 structured routine (trigger description, action label, priority). Falls back to the rule-based extractor on disabled / error / schema failure.
  • Cue reasoning (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.
  • Never on every frame. Per-label cooldowns and a confidence threshold keep this cheap.
  • Audio is never sent. Only short text and structured detection metadata.

Server layer (server/src/llm/)

  • llmConfig.ts loads .env once. Read-only LlmConfig with enabled / apiKey / baseUrl / model / timeout / maxInput / storeRawTranscripts. maskApiKey() ensures the full key never appears in logs.
  • llmTypes.ts, llmErrors.ts, llmSchemas.ts, llmPrompts.ts are the contract surface.
  • openAiCompatibleProvider.ts issues a single fetch() to /chat/completions with response_format: { type: "json_schema", strict: true }, then re-validates the parsed JSON server-side.
  • mockLlmProvider.ts lets tests exercise the same code path without a network. It reuses the rule-based extractor and applies the cue reasoning rules deterministically.
  • llmProvider.ts factory: real provider when HEARER_LLM_ENABLED=true and a key is present; otherwise a DisabledLlmProvider whose status reports the reason honestly.

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). Returns mode/extracted/persisted/summary/confidence/fallbackUsed/reason.
  • services/llmCueReasoner.ts — builds CueReasoningInput from memory summary + live context + recent feedback, asks the provider for a cue, runs the safety guard, returns either a Hearer Cue or a structured rejection.
  • services/safetyGuard.tsvalidateAndRejectUnsafeCue + 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 — returns mode: "llm" | "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".
  • buildApp({ storage, llmProvider }) factory in server/src/app.ts. Tests inject a mock provider; production server.ts is now a tiny entry-point.

Frontend wiring

  • src/api/hearerApi.ts adds llmStatus() and decide() (15 s timeout).
  • ApiMemoryClient exposes llmStatus(). MemoryClient.extract() surfaces the server's mode. LocalMemoryClient returns mode: "rule_based".
  • AudioController.setServerBrain() is an optional hook that REPLACES the local decision when /api/decide returns 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.tsx probes /api/llm/status on mount, installs the server-brain hook when the Memory API is connected, and exposes a Server brain toggle.
  • Header chip reads LLM: connected · gpt-4.1-mini / LLM: mock / LLM: key missing / LLM: disabled fallback — the user always sees what's running.
  • Teach Hearer shows "Saved 1 item via LLM extractor." / "…via rule-based extractor." so the demo is honest about which path ran.

Tests

npm test65 / 65 passing across 18 suites (was 46 / 13). New ones:

  • server/src/__tests__/llmStatus.test.ts — disabled status with 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 a direction cue when no direction supplied.
  • server/src/__tests__/safetyGuard.test.ts — accepts safe cue; rejects stove 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.
  • All 46 prior tests still pass.

npm run build succeeds (~252 kB JS gzipped to ~76 kB).

Privacy / safety

  • API key only on the server.
  • Audio is never sent to the LLM. Only short text + structured detection labels.
  • Default HEARER_STORE_RAW_TRANSCRIPTS=false. Stored text is sanitised for phone / email / credit numbers.
  • 12 s timeout and 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." never "Stove is on.". Direction only mentioned when supplied. No medical / emergency / location-bias language.

How to enable

cp .env.example .env
# edit:
OPENAI_API_KEY=sk-...
HEARER_LLM_ENABLED=true
OPENAI_MODEL=gpt-4.1-mini
npm run server  # http://localhost:8788
npm run dev     # http://localhost:5173

curl http://localhost:8788/api/llm/status should now show provider: "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).

Open in Web Open in Cursor 

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants