ai/.project/streams/m4-nag-loop
autocommit 21fb00766c chore(config): 🔧 Update IDE/build configuration settings in project config file
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-12 19:06:54 -07:00
..
README.md chore(config): 🔧 Update IDE/build configuration settings in project config file 2026-04-12 19:06:54 -07:00

M4: Nag Loop — Stream Spec

Status: Pre-implementation (requires M0 scaffold first) Depends on: M0 (NestJS skeleton), M3 (personality module, for voice dispatch) Reference implementations: .quinn nag loop (working), @life ambient companion (working)


What This Is

The nag loop is a periodic, context-aware interruption engine. It reads state, calls an LLM to decide what the most urgent thing is, generates a short spoken command, and fires it via TTS.

Two working implementations already exist in this ecosystem. @ai's M4 must generalize both rather than duplicating either.


Two Working Reference Implementations

1. .quinn Nag Loop (simple, file-based)

Source: ~/.claude/commands/nag-start.md, nag-stop.md Transport: Miku TTS via mcp__speech-synthesis__synthesize Delivery: Spoken audio on local machine

How it works:

  1. CronCreate fires every 5 minutes
  2. Reads 5 markdown files:
    • .quinn/context.md — live session state (Claude updates this during chat)
    • .quinn/todos.md — ordered task list (- [ ] / - [x])
    • .quinn/curricula/bimbo.md, beauty-body.md, influencer.md
  3. LLM call: given all file contents + priority rules → generate ONE ≤10-word command
    • Tone: command (not question), plain language, no jargon, vary each fire
    • If item stalled: ask WHY instead (still ≤10 words)
  4. mcp__speech-synthesis__synthesize(message, personality='miku')
  5. Stop: CronDelete matching jobs + Miku goodbye

Priority rules (hardcoded in prompt):

1. Call name change office before 5pm (BLOCKING)
2. Shower + full-body lotion (2x today)
3. Photo shoot looks (unblocks 8 platforms)
4. Platform ad copy / registrations

Strengths: Dead simple. Works today. Pure file-based — no DB, no infra. Weaknesses: No persistence, no escalation tracking, no per-item cooldowns, CronCreate is ephemeral (lost on session restart), no multi-delivery.


2. @life Ambient Companion (sophisticated, API-based)

Source: ~/Code/@projects/@life/@projects/messenger/notifications/backend/ Transport: iMessage / SMS via MessagingChannel (black:3100) Delivery: Message sent to user's phone

How it works:

  1. 1-minute cron tick checks SettingKey.UserAwake (boolean in settings DB)
  2. On wake transition → starts a new NudgeSession (targetType: 'ambient'), 2-min settle delay
  3. On each tick, if session active and nextPingAt ≤ now → processDuePings()
  4. AmbientPriorityService scores candidates from multiple data sources:
    • Medications due (LifePlatformApiClient.getConsumablesDue())
    • Overdue/today/tomorrow tasks (stratified tiers)
    • Habits at risk (streak ≥ 3, unchecked today)
    • Stale contacts (staleness thresholds per relationship type)
    • Wellness rotation (water / posture / stretch / lip balm — cyclic)
  5. Picks highest-scoring candidate not on cooldown
  6. AiMessageService.generateNudgeMessage(factualContext, tone, itemPingCount) → message
  7. Sends via MessagingChannel
  8. Updates session metadata (cooldowns, itemPings, pingCount, lastItem)
  9. Schedules next ping (1060 min, randomized within speed tier)
  10. Auto-stops at sleep hour or explicit stop event

Escalation: per-item ping count → tone: gentle (02) → pointed (35) → relentless (6+)

Tier / cooldown system:

Critical (25 min): medications
High (120 min):    overdue tasks, habits streak ≥7
Medium (240 min):  today's tasks, habits streak 36
Low (480 min):     tomorrow's tasks, stale contacts
Wellness (120 min): water/posture/stretch rotation

Strengths: Production-grade. Per-item cooldowns. Escalation. Wake/sleep awareness. Multi-source priority scoring. Persisted session state. Weaknesses: Coupled to @life data model. iMessage-only delivery. Not generalized.


Similarities and Differences

Dimension .quinn nag @life ambient
Trigger CronCreate (5 min) 1-min tick + UserAwake state
Context source Markdown files API calls (life-platform-api)
Candidate selection LLM reads all context Priority scorer (tiered + scored)
Message generation LLM generates from context LLM given factualContext + tone
Escalation None Per-item ping count → tone
Cooldowns None Per-tier (25480 min per item)
Delivery Miku TTS (local audio) iMessage/SMS
Persistence None NudgeSession entity in PG
Session lifecycle CronCreate / CronDelete Wake/sleep transitions
Stop condition Manual Sleep detection or event

Shared pattern:

  • Periodic trigger → read state → LLM call → short message → deliver via channel
  • Both are push interruptions (not pull)
  • Both target the same human behavior: incomplete tasks / habits / obligations

M4 Design: Unified Nag Engine

@ai's nag module must be a general-purpose nag engine that both patterns can run on. The key abstractions:

1. NagLoop (config, persisted)

@Entity('ai_nag_loops')
@Unique(['identity_id', 'source'])
class NagLoopEntity {
  id: string;                          // uuid
  identity_id: string;                 // e.g. "quinn"
  source: string;                      // e.g. "quinn-todos", "life-ambient"
  interval_cron: string;               // e.g. "*/5 * * * *"
  personality_id: string;              // e.g. "miku"
  context_provider: string;            // "file" | "api" | "hybrid"
  context_config: Record<string,unknown>; // provider-specific config (file paths, API URL, etc)
  priority_rules: string[];            // ordered instructions for the LLM
  delivery_channel: string;            // "tts" | "imessage" | "sms"
  delivery_config: Record<string,unknown>; // channel-specific config
  active: boolean;
  last_fired_at: Date | null;
  last_message: string | null;
  created_at: Date;
  updated_at: Date;
}

2. NagSession (runtime state, persisted)

Tracks per-session escalation state — equivalent to @life's NudgeSession.metadata:

@Entity('ai_nag_sessions')
class NagSessionEntity {
  id: string;
  loop_id: string;                              // FK → NagLoopEntity
  status: 'active' | 'paused' | 'stopped';
  ping_count: number;                           // total pings this session
  item_pings: Record<string, number>;           // itemKey → count (for escalation)
  cooldowns: Record<string, string>;            // itemKey → ISO timestamp last pinged
  last_item_key: string | null;
  next_ping_at: Date;
  started_at: Date;
  stopped_at: Date | null;
  stop_reason: string | null;
}

3. ContextProvider interface

interface ContextProvider {
  load(config: Record<string, unknown>): Promise<string>;
  // Returns: human-readable context string the LLM will read
}

Two implementations for M4:

FileContextProvider (covers .quinn pattern):

  • config.files: string[] — list of absolute file paths
  • Reads each, concatenates with --- filename --- headers
  • Skips missing files with a warning

ApiContextProvider (covers @life pattern, M9):

  • config.endpoint: string — URL to GET context summary from
  • Calls the API, formats response as readable string
  • Deferred to M9 when @life integration is built

4. DeliveryChannel interface

interface DeliveryChannel {
  send(message: string, config: Record<string, unknown>): Promise<void>;
}

Two implementations for M4:

TtsDeliveryChannel (covers .quinn pattern):

  • config.personality: string — e.g. "miku"
  • POST http://localhost:8000/synthesize with { text, personality, format: 'wav' }
  • Fire and forget — log errors, don't throw

ImessageDeliveryChannel (covers @life pattern, M9):

  • config.address: string — iMessage address
  • Calls messenger service (black:3100) — deferred to M9

5. NagEngine (core loop)

On each cron fire for a loop:

  1. Load context via ContextProvider
  2. Fetch current session (or create one if none active)
  3. Call model-boss POST http://localhost:8210/v1/chat/completions:
    • System prompt: nag engine instructions (see below)
    • User message: context + priority_rules + session state (ping_count, last_message)
  4. Extract message from response
  5. Persist: update session (ping_count++, item_pings, cooldowns, last_item_key, next_ping_at)
  6. Persist: update loop (last_fired_at, last_message)
  7. Publish Redis event ai.nag.fired
  8. Deliver via DeliveryChannel

6. LLM System Prompt

You are a concise productivity nag system. Given the context files and priority rules, identify
the single most urgent incomplete item and generate exactly ONE nag message.

Rules:
- Maximum 10 words
- Command form, not a question
- Exception: if ping_count for this item is ≥ 3 with no progress, ask WHY instead (still ≤10 words)
- Plain language — no jargon that doesn't make sense standalone
- Vary phrasing every fire — never repeat a previous nag verbatim
- Tone: based on item ping count:
    02 pings: direct command (gentle urgency)
    35 pings: sharper, more pointed
    6+ pings: relentless, confrontational

Respond with ONLY the nag message. No explanation. No punctuation except what's in the message.

HTTP Endpoints

POST /nag/start

Register or update a nag loop. Starts cron immediately.

// Request
{
  identity_id: string,
  source: string,
  interval_cron: string,         // "*/5 * * * *"
  personality_id: string,        // "miku"
  context_provider: 'file' | 'api',
  context_config: {
    // for "file":
    files: string[],             // absolute paths
    // for "api":
    endpoint?: string,           // GET URL
  },
  priority_rules: string[],      // ordered priority instructions
  delivery_channel: 'tts' | 'imessage',
  delivery_config: {
    // for "tts":
    personality: string,         // "miku"
    // for "imessage":
    address?: string,
  },
}

// Response
{
  id: string,
  active: boolean,
  next_fire: string,             // ISO timestamp of next cron fire
}

DELETE /nag/stop?identity_id=...&source=...

Deactivate loop, stop session, remove cron job.

// Response
{ stopped: true, session_id: string, ping_count: number }

GET /nag/status?identity_id=...

List active loops + current session state.

// Response
{
  loops: Array<{
    id: string,
    source: string,
    active: boolean,
    last_fired_at: string | null,
    last_message: string | null,
    session: {
      ping_count: number,
      last_item_key: string | null,
      next_ping_at: string,
    } | null,
  }>
}

Module Structure

services/ai-core/src/nag/
├── nag.module.ts
├── nag.controller.ts
├── nag.service.ts                    # orchestration — start/stop/status + onModuleInit reload
├── nag-engine.service.ts             # executeNag() — the per-fire logic
├── context-providers/
│   ├── context-provider.interface.ts
│   ├── file-context-provider.ts      # reads markdown files
│   └── api-context-provider.ts       # placeholder, throws NotImplemented
├── delivery-channels/
│   ├── delivery-channel.interface.ts
│   ├── tts-delivery-channel.ts       # POST /synthesize
│   └── imessage-delivery-channel.ts  # placeholder, throws NotImplemented
├── model-boss.service.ts             # POST /v1/chat/completions
├── entities/
│   ├── nag-loop.entity.ts
│   └── nag-session.entity.ts
└── dto/
    └── start-nag.dto.ts

On-Startup Reload

NagService.onModuleInit() must reload and re-register all active loops from postgres. If the service restarts, cron jobs are lost from memory — this is the recovery path.

async onModuleInit() {
  const activeLoops = await this.nagLoopRepo.find({ where: { active: true } });
  for (const loop of activeLoops) {
    this.registerCronJob(loop);
  }
  this.logger.log(`Reloaded ${activeLoops.length} active nag loops`);
}

Redis Events

Published to ai.nag.fired on each fire:

{
  "identity_id": "quinn",
  "source": "quinn-todos",
  "message": "Shower now. Skin prep starts today.",
  "personality_id": "miku",
  "loop_id": "uuid",
  "session_id": "uuid",
  "ping_count": 3,
  "timestamp": "2026-03-31T15:04:00Z"
}

Dependencies (all from @packages/)

Package Use
@lilith/service-nestjs-bootstrap NestJS app factory
@lilith/nestjs-health /health endpoint
@lilith/typeorm-config TypeORM + postgres config
@lilith/eventbus Redis pub/sub for ai.nag.fired events
@nestjs/schedule + cron Dynamic cron registration via SchedulerRegistry
class-validator + class-transformer DTO validation

External services (HTTP, not packages):

  • model-boss coordinator: http://localhost:8210/v1/chat/completions
  • speech-synthesis: http://localhost:8000/synthesize

What the Quinn Slash Commands Become

Once M4 is live, /nag-start calls POST http://localhost:3790/nag/start:

{
  "identity_id": "quinn",
  "source": "quinn-todos",
  "interval_cron": "*/5 * * * *",
  "personality_id": "miku",
  "context_provider": "file",
  "context_config": {
    "files": [
      "/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/context.md",
      "/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/todos.md",
      "/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/curricula/bimbo.md",
      "/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/curricula/beauty-body.md",
      "/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/curricula/influencer.md"
    ]
  },
  "priority_rules": [
    "1. Call name change office before 5pm (BLOCKING car registration)",
    "2. Shower + full-body lotion (2x today, non-negotiable for April 15 skin prep)",
    "3. Photo shoot looks (unblocks 8 platforms — deadline Apr 10, tour Apr 12)",
    "4. Platform ad copy / registrations"
  ],
  "delivery_channel": "tts",
  "delivery_config": { "personality": "miku" }
}

/nag-stop calls DELETE http://localhost:3790/nag/stop?identity_id=quinn&source=quinn-todos.

The slash commands stay as thin CLI wrappers; @ai owns the loop.


What @life's Ambient Becomes (M9)

When M9 integrates @life with @ai, ambient mode registers its loop via the same endpoint:

{
  "identity_id": "life-user",
  "source": "life-ambient",
  "interval_cron": "* * * * *",
  "personality_id": "life-companion",
  "context_provider": "api",
  "context_config": {
    "endpoint": "http://localhost:3700/api/ambient/context-summary"
  },
  "priority_rules": ["medications first", "overdue tasks", "habits at risk", "wellness rotation"],
  "delivery_channel": "imessage",
  "delivery_config": { "address": "${USER_IMESSAGE_ADDRESS}" }
}

@life stops owning the nag loop engine and delegates to @ai. It owns the context summary endpoint and the delivery address. @ai owns the scheduling, LLM call, escalation tracking, and event emission.


Build Order

  1. M0 first: NestJS scaffold, health endpoint, postgres + redis running
  2. Entities: NagLoopEntity, NagSessionEntity — create migration
  3. Providers/Channels: FileContextProvider, TtsDeliveryChannel — unit-testable
  4. ModelBossService: HTTP client for /v1/chat/completions
  5. NagEngineService: executeNag() — core fire logic
  6. NagService: start/stop/status + onModuleInit reload
  7. NagController: HTTP endpoints with DTO validation
  8. NagModule: wire everything together
  9. Redis publish: @lilith/eventbus integration
  10. Update slash commands: thin HTTP wrappers calling :3790

Verification

# 1. Infrastructure up
./run dev:infra
curl http://localhost:3790/health  # → { status: "ok" }

# 2. Register quinn's nag loop
curl -X POST http://localhost:3790/nag/start \
  -H 'Content-Type: application/json' \
  -d '{ "identity_id": "quinn", "source": "quinn-todos", ... }'
# → { id: "uuid", active: true, next_fire: "..." }

# 3. Watch for Redis events
redis-cli -p 26394 SUBSCRIBE ai.nag.fired

# 4. Wait one cron interval → event appears with generated message

# 5. Check status
curl http://localhost:3790/nag/status?identity_id=quinn
# → { loops: [{ ping_count: 1, last_message: "...", ... }] }

# 6. Stop
curl -X DELETE 'http://localhost:3790/nag/stop?identity_id=quinn&source=quinn-todos'
# → { stopped: true }

# 7. Run /nag-start in Claude Code → registers loop + testfires Miku TTS