Skip to content

bonellri/LockedIN

Repository files navigation

lockedin

An always-on iMessage bot with an interactive CLI. It answers incoming messages from known users with an LLM (Pioneer AI), and lets you trigger check-in messages on demand from the command line.

Requirements

  • macOS (this reads the local Messages database — it will not work elsewhere)
  • Bun ≥ 1.0 (iMessage bot)
  • Node ≥ 20 (calendar auth / CLI helpers)
  • Messages.app signed in to iMessage
  • A Pioneer AI API key
  • A Google Cloud project with the Calendar API enabled (for scheduling tools)

Setup

1. Install dependencies

bun install
# or: npm install

2. Add your API key

cp .env.example .env

Then edit .env and paste in your Pioneer key (and optional calendar overrides).

3. Connect Google Calendar (one-time, per user)

Each user connects their own Google account, so run this once per person and sign in as that person each time:

npm run auth -- --user arne
npm run auth -- --user richie

Tokens are stored separately in .tokens/<user>.json (gitignored). A user who skips this still chats normally — they just have no calendar.

Register this redirect URI on your OAuth web client first:

http://localhost:8765/oauth2callback

After that the bot refreshes each token silently. Sanity check: npm run smoke:calendar (add a user key to check a specific one).

4. Start Actian (optional, for semantic search)

docker compose up -d

This runs Actian VectorAI DB locally — REST on 6573, web UI on http://localhost:6575. Without it the bot still answers schedule questions using calendar keyword search; with it, meaning-based questions work too (see below). Then index a user's events from the REPL:

lockedin(arne)> /index
  Indexed 214 events.

5. Grant macOS permissions

The bot needs two separate permissions, both for the terminal app you run it from (Terminal, iTerm, VS Code, …):

Permission Where Why
Full Disk Access System Settings → Privacy & Security → Full Disk Access to read the Messages database
Automation → Messages System Settings → Privacy & Security → Automation to send replies

After granting Full Disk Access, fully quit and reopen your terminal — the permission only applies to newly launched processes.

Automation usually prompts on first run ("Terminal wants to control Messages") — click OK. To trigger it manually:

osascript -e 'tell application "Messages" to get name'

6. Run it

bun start

On startup each user reports separately:

📅 arne: calendar ready (primary, Europe/Berlin)
📅 richie: no calendar — `npm run auth -- --user richie`

Text a known user things like “what's on my calendar tomorrow?”, “when am I free next week?”, or “when are my flights again?” — lockedin.log / /log should show 🔧 list_events, 🔧 find_free_time or 🔧 search_events before the reply.

What the user can send

Separate from the operator CLI below — these are commands the user texts:

Command What it does
/feedback <text> tell the agent how to treat you — tone, timing, what to drop
/reflect check in on how things are going; the next message is captured
/profile see what the agent thinks about you and how you like to be talked to
/help list these

/feedback is deliberately the only way to set preferences: "be blunter" and "don't text before 9am" are the same act, so they share one command. The editor model decides which section of the profile each statement belongs in.

Anything not starting with / is a normal conversation.

Adding a person

  1. Register them in src/config.ts under USERS — a short key, their name, and their number in E.164 form (+4917645608285, no spaces).
  2. Add them as a Google test user. In Google Cloud Console → APIs & ServicesOAuth consent screenAudience, add their Google address under Test users. While the app is unpublished, Google refuses consent for anyone not on that list — this is the step people miss.
  3. Run consent as them: npm run auth -- --user <key>. The browser opens on this machine, so they need to sign in themselves — in person or over a screen share. Their token lands in .tokens/<key>.json.
  4. Index their calendar: start the bot, /setuser <key>, then /sync.
  5. Set their voice if they want the plainer one: /genz off.
  6. Have them text the Mac from that number. Tell them to send /help to see what they can do.

Nothing else is per-user — the Pioneer key, Actian, and macOS permissions are all shared by the machine.

Voice: Gen Z mode

Each user has a genZ toggle stored in .lockedin-state.json. On (the default) is the slang-and-emoji register; off is the same person written plainly. Same briefing, both modes:

on — heard u're trying to ship that onboarding fix today and actually get some sleep for once ngl … u surviving this shift or what 💀

off — That onboarding fix better finally cooperate today so you can actually hit your midnight sleep goal. … Did you end up finding the root cause of that bug yet?

Toggle with /genz on|off. It applies everywhere — replies, briefings, reminders.

Event reminders

The bot texts ~30 minutes before events worth remembering, and stays quiet about the rest. Deciding which is the interesting part: structural rules discard the obvious noise (all-day entries, blocks the agent booked itself), and only genuinely ambiguous events cost an LLM call, whose verdict is cached on the event.

high    Doctor appointment — Dr. Weber      → reminded
high    LH1234 to LIS                       → reminded
high    Dinner with Anna                    → reminded
normal  Daily standup                       → quiet
low     Deep work: Q3 planning doc          → quiet

Only high is ever texted about. A failed or unparsed verdict falls back to normal, so an error means silence rather than a spurious 3am text.

How calendar, vector DB and reminders stay in sync

One cache — .lockedin-events.json — is the single structure all three read:

field used by
fingerprint sync: detects a changed event so it is re-embedded and re-judged
importance reminders: only high is texted about
indexedAt sync: what has reached Actian
remindedAt reminders: the dedupe key, so nobody is texted twice

Two cadences run against it. Sync (every 15 min) is the only thing that talks to Google: it fetches, diffs by fingerprint, embeds changed events into Actian, assesses new ones, and prunes deletions. Unchanged events cost nothing on a re-sync, which matters because embedding and assessment are both paid calls. The reminder tick (every 60 s) reads only the cache — no API calls at all — so it can run often and cheaply.

remindedAt is written before the send is attempted, on the grounds that a double text is worse than a missed one.

Profiles: how the agent learns

Each user has a markdown profile at profiles/<key>.md with five sections — communication preferences, what seems to work, context, timing, and reflections. It is injected into every prompt, and it is plain markdown so you can also just open and edit it by hand.

When feedback or a reflection arrives, a second LLM pass folds it into the document. Because markdown carries no schema, every write is validated (required headings must survive, 6k character cap) and snapshotted first — a mangled rewrite is rejected, and /profile undo reverts the last change.

Precedence matters and is stated in the prompt: what the user asked for directly outranks what the agent inferred.

Using the CLI

Two things run at once: a background poller that auto-replies to incoming DMs, and a prompt where you type commands. Background activity is not printed — it goes to lockedin.log and the /log feed — so the prompt stays readable.

lockedin> /setuser arne
  Selected Arne (+4917645608285)
lockedin(arne)> /daily
  Generating daily briefing for Arne…
  Sent to Arne: Hope you managed to ship that onboarding fix today…
Command What it does
/setuser <arne|richie> select who commands act on
/who show the selected user
/daily [--dry] generate and send a daily check-in
/weekly [--dry] generate and send a weekly rundown
/say <text> send a literal message (no LLM)
/reset forget the conversation so far — clean slate
/profile [undo] show their learned profile, or revert the last edit
/genz [on|off] slang+emoji voice, or the plainer serious one
/sync [full] pull their calendar into the cache + vector DB
/reminders upcoming important events and reminder status
/remind [--dry] send any reminders due right now
/askreflect ask them for a reflection (weekly feedback round)
/log watch the live feed — Enter to exit
/help list commands
/quit stop lockedin

--dry prints the generated message instead of sending it — use it while tuning prompts.

Customizing the messages

Prompt text lives in prompts/daily.md and prompts/weekly.md, so you can tune the tone without touching code. Templates use {{placeholders}}:

{{user}} · {{date}} · {{calendar}} · {{mood}} · {{goals}} · {{recent_messages}}

{{calendar}} is live Google Calendar data when authorized. {{goals}} and {{mood}} are read from the user's profile — the Goals section, and their most recent reflection. When a section is empty the placeholder says so explicitly and the templates instruct the model to ask rather than invent.

Calendar CLI (optional)

Separate from the iMessage REPL — useful for seeding a demo calendar or planning offline:

Command What it does
npm run auth -- --user <key> One-time Google consent for that user
npm run preview Example 14-day schedule + free time (no Google)
npm run seed Write the example schedule into your calendar
npm run agenda / free / plan Inspect / plan focus blocks
npm run cal -- clear Remove seeded / agent-written events only

Project layout

lockedin.ts           entry point — calendar init, poller, CLI
src/config.ts         users, voice, env validation
src/logger.ts         file log + ring buffer + live subscribers
src/llm.ts            Pioneer client (+ tool-calling loop)
src/messages.ts       iMessage send / history / poll loop
src/autoreply.ts      auto-reply (uses calendar tools when available)
src/context.ts        briefing context (live calendar + placeholders)
src/briefing.ts       daily & weekly generation
src/cli.ts            iMessage REPL (/daily, /reset, /profile, …)
src/usercmd.ts        commands the USER texts (/feedback, /reflect, /profile)
src/profile/          markdown profile store + the editor LLM
src/retrieval/        semantic (Actian) + keyword (Google) event search
src/events/           event cache, sync, importance, reminders
src/calendar/         Google Calendar layer + agent tools
prompts/              editable briefing templates

Users live in src/config.ts as the single source of truth — the auto-reply whitelist and the /setuser options are both derived from it. Add a person there and everything else follows.

Notes

  • Direct messages only. Group chats are ignored, even if a known user posts in one.
  • History comes from chat.db, not a separate database, so it stays in sync with what you see in Messages.app.
  • Messages sent while the bot is offline are skipped — it only responds to messages that arrive while it's running.
  • Calendars are per user. Each person authorizes their own Google account and gets their own token; nobody sees anyone else's schedule.
  • Far-future questions work without a date range. The search_events tool searches the whole calendar, so "when are my flights" resolves for a trip months out. Two providers answer it together: Actian matches on meaning ("when do I fly to Lisbon" finds an event titled "LH1234 to LIS"), Google matches on exact keywords. Results are merged and deduped; if Actian is not running, keyword search alone still answers.
  • Semantic results have a score floor (MIN_SCORE in src/retrieval/actianClient.ts). Nearest-neighbour search always returns something, so without a floor an unrelated question comes back with the least unrelated event and the model treats it as an answer.
  • Re-indexing is safe. Point ids are derived from the Google event id, so /index overwrites rather than duplicating, and edited events update in place.
  • /reset doesn't delete anything. Your Messages history is untouched; the command records a cutoff (in .lockedin-state.json) and the bot ignores anything before it. The cutoff is per user and survives restarts. /who shows it.

Troubleshooting

Nothing happens when you text it:

  1. Run /log — if there's no 📩 line, the message wasn't picked up.
  2. Confirm the sender is in USERS (src/config.ts) and it's a direct message.
  3. Re-check the two permissions above, and remember to restart your terminal after granting Full Disk Access.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages