Skip to content

Repository files navigation

ai_npc — Unscripted conversation for Cyberpunk 2077

Adds an LLM-driven texting layer to the in-game phone: supported contacts answer with generated dialogue instead of scripted messages, with awareness of quest state.

Other mods can take ai_npc as an optional dependency: @if(ModuleExists("AiNpc")) lets a single build gain an AI voice where ai_npc is installed and do without where it is not. See docs/API.md.

Requirements

Install these first (all are standard framework mods):

Dependency Purpose
RED4ext plugin loader
redscript compiles this mod's .reds at launch
Codeware UI + input callbacks
RedHttpClient HTTPS calls to OpenRouter
RedData JSON parsing
RedFileSystem reads/writes settings + history
Mod Settings in-game settings menu

All seven are hard requirements: the mod calls into each one from redscript, so a missing framework is a compile error at launch, not a degraded feature.

Four more are optional, each unlocking one thing and inert when absent:

Optional Unlocks
Cyber Engine Tweaks the in-game window shipped in the archive (bin\x64\plugins\cyber_engine_tweaks\mods\ai_npc_debug\): Setup — enter and test your API key, switch provider, no restart — and Journal — see Importing a lost history
the Claude CLI, signed in the Claude CLI provider — replies come from your existing subscription instead of a metered API key; see The CLI lanes
the Codex CLI, signed in with ChatGPT the Codex CLI provider — the same trade on a ChatGPT subscription; see The CLI lanes
BrowserExtension the chat as a website on every in-game computer — a second surface beside the phone. Every declaration that touches it carries @if(ModuleExists(...)) (src\r6\scripts\ai_npc\AiNpcTerminalSite.reds), so the mod compiles and runs identically without it

Phone Extension Framework is not a dependency. ai_npc coexists with it (its contact wrapper is deliberately sorted after it — see src\r6\scripts\zzz_ai_npc_phone\) and exposes AiNpcContactHash for mods that do use it, but nothing here calls into it.

Install

OpenRouter is the supported lane, and the only one meant for playing. Each player brings their own key, the mod intermediates nothing, and at the measured pace it costs cents a month (docs/MODEL_COSTS.md). The subscription lanes are there so that people writing characters and add-ons can test against a real model without paying per message — playing through them is outside what a coding-agent plan is sold for, and either provider may act on the account. The reasoning, with the citations, is in docs/DISTRIBUTION.md.

The packaged archive carries a FOMOD installer, so Vortex and MO2 ask the one question the archive cannot answer for itself: which provider the characters should use. The answer travels as a file (presets\<Provider>\...\AiNpcInstallPreset.reds, generated by tools\package.ps1) and is applied once, on the first session — after that the game's own settings win, so a choice made in the menu is never overruled by an installer choice from three weeks ago.

Everything else is decided from that answer rather than asked again. The CET window is always installed, because it is where the key is entered and it is inert without CET; the RED4ext plugin (ai_npc.dll) carries the CLI lanes. Framework requirements are not checked here — Nexus lists them on the mod page, which is where Vortex reads them.

The installer does not ask for your API key. A FOMOD has no text field: it can only decide which files get copied. The key is entered in game instead, in the CET overlay, where it can be tested against the provider and changed without restarting — see The setup window.

Installing by hand, or with a manager that ignores the FOMOD, copies the contents of src/ into your Cyberpunk 2077 folder, so you end up with:

Cyberpunk 2077\r6\scripts\ai_npc\*.reds

Or unzip the packaged release (tools\package.ps1, see below) at the game root.

On first launch the mod creates these files in Cyberpunk 2077\r6\storages\AiNpc\:

  • settings.json — API keys and model selection (edit this)
  • journal.index.json + journal.b<N>.jsonl — chat history, tied to your savegames (see Conversation storage)
  • characters.example.json — schema reference for character overrides, rewritten every launch
  • facts.example.json — schema reference for the quest-fact bridge, rewritten every launch
  • config-report.json — what the character/prompt config loaded and rejected, rewritten every launch

See Customising characters and prompts for the optional files you can add next to them.

Which model to enter in the setup window

Basis: 38 messages an hour, measured over 15 real sessions. 2 h/day = 2 280 messages a month. OpenRouter prices read 2026-08-28, cached where the provider discounts a prefix. Measurements: docs/MODEL_BENCH.md.

model 1 000 messages 2 h/day messages for 1 $ reply defects /54 Normal tier /24 pick
qwen/qwen3-235b-a22b-2507 0.24 $ 0.54 $ 4 200 2.7 s 0 23 default
deepseek/deepseek-v4-flash 0.26 $ 0.60 $ 3 800 4.6 s 1 14
meta-llama/llama-4-maverick 1.13 $ 2.57 $ 890 2.0 s 1 1 if you lower the content level
deepseek/deepseek-chat-v3-0324 1.41 $ 3.21 $ 710 3.7 s 1 4 fallback
google/gemma-4-31b-it:free 0 $ 0 $ 2.0 s 0 /34 not measured 46 of 54 requests: HTTP 429

defects /54 — replies out of 54 carrying a name prefix, an emoji, an invented timestamp, a control token or markdown. Normal tier /24 — explicit replies out of 24 on an already explicit thread, lower is better: 23 means the Normal content level is ignored.

The setup window

Open the CET overlay: AI NPC, tab Setup. It needs Cyber Engine Tweaks, and it is the short way through everything the next section describes by hand.

  • what you are on — provider, model, whether a key is present (masked: enough to recognise it, never enough to reuse it), and whether the mod considers itself usable.
  • provider — four buttons. The switch applies to the next message immediately, and is pushed into Mod Settings so it survives a restart. If Mod Settings will not take it, the window says so rather than letting you find out at the next launch.
  • the fields that matter for that provider — key, model, routing, endpoint. Saving writes settings.json and the running game picks it up at once. Only the keys listed below can be written, so a typo cannot invent a setting that nothing reads.
  • Test connection — sends one real request, built by the same two functions that build a character's reply, and prints what came back: the model's answer, or the provider's own error, or the explanation for a transport failure (the -no-tls trap, and the RedHttpClient version that goes with it).

The same four things are on the console, for when the overlay is not the fastest way there — though a key typed there stays in the CET scrollback, which the window's masked box avoids:

print(GetMod("ai_npc_debug").setup())
print(GetMod("ai_npc_debug").provider("OpenRouter"))
print(GetMod("ai_npc_debug").set("openRouterApiKey", "sk-or-v1-..."))
print(GetMod("ai_npc_debug").test())

Configuration

Everything below can be done from the setup window above. It is written out because the file is the source of truth, because a text editor works without CET, and because this is what you will be asked to check when something does not work.

r6\storages\AiNpc\settings.json:

{
    "openRouterApiKey": "",
    "openRouterModel": "google/gemma-4-31b-it:free",
    "openRouterProvider": "Auto",
    "claudeCliModel": "sonnet",
    "claudeCliPath": "",
    "codexCliModel": "",
    "codexCliPath": "",
    "appearance": "",
    "playerDescription": "",
    "importConversationsFrom": ""
}
  • OpenRouter — needs a key from https://openrouter.ai/keys; free model ids end in :free. openRouterProvider is "Auto" or a provider slug such as "google-ai-studio". The shipped default is free and works, but a free model is throttled by whoever serves it: measured on 2026-08-28, it answered 46 requests out of 54 with "rate-limited upstream". It is a way to try the mod, not a way to play an evening. For that, qwen/qwen3-235b-a22b-2507 (~0.55 $/month at two hours a day) is the cheapest that does the job: it emits the appointment commands 18 times out of 20 on real recorded conversations, with no mechanical defect over 54 replies. What it does not do is respect the Normal explicitness tier — leave the mod on its top tier, or pick meta-llama/llama-4-maverick (~2.56 $/month), which holds that tier better than anything measured and takes every appointment, 20 out of 20. deepseek/deepseek-chat-v3-0324 (~3.21 $/month) matches Maverick and is the fallback when a provider outage makes it unavailable. Do not pick on price alone: a model can write a perfect reply and silently leave the command out, and nothing in the game will tell you. Ten models were measured against four criteria — see docs/MODEL_BENCH.md, and docs/MODEL_COSTS.md for what an hour of play costs.

  • Claude CLI — no key, ever. Runs the claude command you are already signed into, through ai_npc.dll. claudeCliModel is a CLI alias (haiku, sonnet, opus); claudeCliPath is only needed when claude is not on the PATH the game inherited. See "The CLI lanes" below.

  • Codex CLI — the same idea on a ChatGPT subscription, through the codex command and the same DLL. codexCliModel is a model id (empty lets the CLI use its own default); codexCliPath is only needed when codex is not on the PATH the game inherited. The lane needs codex login status to report a ChatGPT sign-in: an API key is refused rather than billed. Two caveats, both stated rather than discovered in play — it has never been run in game, and the safe-for-work tier's measurement was taken on the Claude lane, where the tier text is a real system prompt. Codex has no system prompt, so that number does not transfer.

  • memory — no longer here. Whether a character remembers conversations that have scrolled out of its window, and how much it keeps, are two settings in Mod Settings → AI NPC → Memory (2026-08-22). A memoryEnabled key left in this file is ignored, and startup logs one line saying so. See docs/MEMORY.md.

  • appearance — what V looks like, in your own words: tattoos, chrome, scars, hair, the jacket they never take off. Empty by default, and then the mod says nothing about V's looks at all. What you write is added to what the game already knows — V's life path, read from the save, and V's gender, read from the character — not instead of it. It goes in verbatim, so write a sentence, not a list of keywords.

    This lives in the file rather than in the menu because Mod Settings has no text field (booleans, numbers and dropdowns only). It replaced four dropdowns — perceived origin, hair, build, breast size — which could name a hair colour and could not name a tattoo.

  • playerDescription — the same description, but replacing the whole section instead of adding to it, life path included. Empty by default. This is the escape hatch for a mod or a player who needs the mod to stop stating anything of its own about V; for normal use, appearance is the one you want.

  • importConversationsFrom — a repair hatch, empty except when you are using it: a journal pointer such as "b14:467", which the next save you load adopts as its own conversation history. For when a save is lost and play resumes from an earlier one. See "Importing a lost history" below, and delete the key once it has run.

  • installPreset — written by the mod, not by you: the provider the FOMOD installer was told to use, recorded once it has been applied. It is what stops the installer's answer from being re-applied over a choice you made later in the menu. Deleting it makes the next launch apply the installer's choice again.

Keys are never stored in the scripts, so the packaged mod can be shared safely.

Everything else (provider choice, tone, memory, logging) lives in the in-game Mod Settings → AI NPC menu.

Debug Mode

Off by default. A failed message always shows the same in-fiction line -- the Night City carrier is unreachable, call support -- which keeps the chat window diegetic and tells you nothing. With Debug Mode on, a second message follows it in the same conversation with the provider, the url and the cause: the same three things FTLogError writes to the log, put where the failure happened instead of in a file you have to know about. The technical message is never translated, never written to the conversation history, and never sent to the model -- the character did not say it.

What a message costs

With Enable Logs on, every request writes one line to the CET console when its answer lands -- the reply itself, the retry that repairs a broken command, and the compaction that maintains a character's memory in the background:

[ai_npc]: request lane=speaking contact=panam provider=OpenRouter model=openai/gpt-oss-120b
          chars_system=16321 chars_user=853 status=200 prompt_tokens=4504 completion_tokens=312
          total_tokens=4816 finish=stop chars_per_token=3.81 charged=4816 day_total=48160

Metadata only: no prompt, no reply, nothing either party wrote. That is what makes it safe to paste into a bug report, and it is what a free provider actually meters -- see docs/PROMPT_BUDGET.md, which turns these numbers into messages per day.

Three things are worth knowing about the line. lane=thinking is the mod compacting memory on its own initiative, and it spends the same daily budget as everything the player types. usage=absent means the provider reported nothing, not that the request was free. And finish=length means the reply was cut short by a token limit rather than the character choosing to stop -- which is the difference between a model ignoring a command and a command that never got written.

With Debug Mode on as well, each request and reply is also dumped in full. That log then contains the conversation, so it is for hunting a malformed body, not for leaving on.

The daily token limit

Off by default: the mod sends as much as you ask it to. Switched on, in Mod Settings → AI NPC → Budget, it stops sending once the day's tokens are spent and says so in the chat -- as a data allowance that ran out, not as an error, because that is what it is.

The count covers every save at once. It lives in r6/storages/AiNpc/usage.json, outside the savegame, because a provider meters one key and does not care which playthrough spent it. Everything counts against it: the replies, the retry that repairs a broken command, and the compaction that maintains memory in the background. There is no exemption for a local model -- one rule, one tally. If you run one, leave the limit off.

"Day" is the provider's own UTC day, read from the Date header of its answers, since that is the clock its quota actually resets on. One consequence worth knowing: the first message of each session always goes through, even on a spent day -- it is the answer to that message that tells the mod what day it is. If the day has turned, the count starts again from zero.

The default is 200 (thousand tokens), which is the free tier measured on Groq: about forty messages, or twenty-five once characters remember things. At four fifths of the budget the network sends one warning, once a day. See docs/TOKEN_BUDGET.md.

What the menu stopped asking

Six things it no longer asks about. Most of them because the game already answers them -- a setting the game can answer is a setting the player can get wrong, and a wrong answer is stated as fact to every character, in every playthrough. The last one because a dropdown was the wrong shape for the question:

Was a setting Now answered by
Romance: Panam / Judy / River / Kerry the save's romance facts (sq027_panam_lover, sq030_judy_lover, sq029_river_lover, sq028_kerry_relationship)
Romance: Songbird / Rogue / Viktor / Takemura removed — the base game has no romance for them
Character the contact you open on the phone
Gender the character V was created with (GetResolvedGenderName)
Language the game's own text language (/language in the game options)
V's appearance: origin / hair / build / breast size nothing -- four closed lists could name a hair colour and not a tattoo. Replaced by the free-form appearance key in settings.json, above

The chat UI and the generated replies follow that language. A locale the mod ships no prompt text for -- Japanese, Polish, Chinese -- falls back to English rather than to silence: an English instruction produces an English reply, no instruction produces whatever the model feels like. The Mod Settings menu itself stays in English: its labels are compile-time annotations and Mod Settings has no localisation for them.

The CLI lanes

Two of the three providers do not call an API at all: they run a command-line tool you are already signed into, so the replies are billed against a subscription you already pay for rather than per token.

Cyberpunk (REDscript)  -->  ai_npc.dll  --CreateProcess-->  claude -p
                                                            codex exec

REDscript cannot spawn a process, which is why this needs native code. The plugin (red4ext\plugins\ai_npc\ai_npc.dll) runs the CLI on a worker thread, reads what it wrote, and hands the reply back to the mod in the same shape an HTTP provider would have returned. Pick Claude CLI or Codex CLI as the model in Mod Settings.

No Python, no port, no launch argument. An earlier version reached the CLI through a local HTTP bridge, because the mod could only speak HTTP. That cost four things a player had to get right — RedHttpClient's version, -no-tls, Python on the PATH, and a free port 8787 — and all four failed with the same message. None of them applies any more.

If you set -no-tls for the old bridge, remove it. It does not permit http:// alongside https://; it turns TLS off, and OpenRouter is now the only lane that uses HTTPS. Left in place it breaks that lane silently, with the same [NO SIGNAL: HTTP 0] it was added to fix.

Requirements for the Claude lane

claude installed and signed in. The plugin checks with claude auth status --json before the first request of each session, and refuses two states outright rather than proceeding:

  • not signed in — it says to run claude login;
  • signed in, but with an API key — the lane exists to spend a subscription, and a key would bill you per message for what that subscription covers. If ANTHROPIC_API_KEY is set in your environment it is removed from what the CLI inherits; if the CLI still reports one, the request is refused and the variable is named.

loggedIn alone is not the check, and that is not caution: measured on Claude Code 2.1.241, claude auth status --json with ANTHROPIC_API_KEY set still reports "loggedIn": true and "authMethod": "claude.ai". What proves a subscription is apiKeySource being absent and subscriptionType being non-null.

If claude is not on the PATH the game inherited — the native installer puts it in %USERPROFILE%\.local\bin, and a PATH change made while the game is running is not visible to it — set claudeCliPath in settings.json to the full path.

Requirements for the Codex lane

codex installed and signed in with ChatGPT. The plugin runs codex login status before the first request of each session and reads what it printed:

  • Logged in using ChatGPT — the lane runs;
  • Logged in using an API key — refused, with the way out named (codex logout, then codex login). Same reason as the Claude lane: a key bills you per message for what the subscription already covers;
  • Logged in using Agent Identity — refused; it bills an organisation rather than a subscription;
  • Not logged in, or anything the plugin cannot recognise — refused. A check that cannot be read is never treated as a pass.

CODEX_API_KEY, CODEX_ACCESS_TOKEN, OPENAI_API_KEY and the workload-identity variables are removed from what the CLI inherits, for the status check and the request alike. CODEX_HOME is left alone: that is where your sign-in lives.

Codex reports no plan tier at all, so unlike the Claude lane there is nothing to verify beyond the sign-in method. A ChatGPT account without Codex quota passes the check and fails at the first message, where the CLI's own sentence reaches you.

Set codexCliPath in settings.json if codex is not on the PATH the game inherited.

This lane has never been run in game. It was written against OpenAI's published documentation — nothing on the development machine has the Codex CLI installed — so its flags are asserted against the documentation rather than against a measurement. Two consequences: a flag a future version drops shows up as a refusal that names the flag, and the safe-for-work tier's "0 explicit replies in 110 runs" was measured on the Claude lane, where the tier text is a real system prompt. Codex has none, so that number does not transfer to this lane.

Isolation

By default the CLI is a coding agent: it loads CLAUDE.md from the working directory and from your home, plus project settings, skills, plugins, hooks and MCP servers, and it carries a system prompt about editing code. All of that would bleed into what a character says. The plugin runs it with:

Flag Disables
--safe-mode CLAUDE.md, skills, plugins, hooks, MCP, custom commands and agents
--system-prompt-file the coding-agent system prompt, replaced by the character's
--strict-mcp-config every MCP config on the machine
--disable-slash-commands skills reachable via /name
--tools "" every built-in tool — an allowlist naming none, so a tool added by a future CLI version cannot appear by default
--disallowed-tools the same tools again, named one by one: a second line of defence if the flag above is ever ignored
--no-session-persistence session files on disk
empty temp cwd anything discoverable next to the working directory

Do not swap --safe-mode for --bare. It disables more, but its own help states that auth becomes strictly ANTHROPIC_API_KEY and that OAuth is never read — which defeats the reason for using the CLI rather than the API.

The system prompt travels as a file rather than as an argument, which is what keeps this lane off the 32767-character ceiling Windows puts on a command line. Only the player's message goes on stdin. The two are never joined in REDscript: where the system block goes is the backend's decision, and it settles the instruction hierarchy the safe-for-work tier depends on.

Codex is the same problem with a smaller toolbox:

Flag Disables
--disable shell_tool the shell — the tool leaves the session, it is not merely confined
-c features.shell_tool=false the same switch again, in config form: a second line of defence
-c features.multi_agent=false subagent tools
-c features.hooks=false lifecycle hooks
--ignore-user-config config.toml, and with it every MCP server, plugin, hook and profile
--ignore-rules user and project execpolicy .rules
-c project_doc_max_bytes=0 AGENTS.md, from any directory
-c tools.web_search=false web search
-c tools.view_image=false the local-image tool
-c history.persistence="none", --ephemeral transcripts and session files on disk
-c approval_policy="never" stopping to ask, which in a non-interactive run is a hang
--sandbox read-only writing anything, and reaching the network
empty temp cwd, --skip-git-repo-check anything discoverable next to the working directory (Codex refuses to run outside a git repository, hence the second flag)

Why the shell is removed rather than sandboxed. Codex's own documentation says its read-only sandbox permits reading files anywhere on the filesystem. A confined shell can therefore still read your documents and put what it found in a bubble — which the mod writes to its journal. --disable shell_tool is the Codex equivalent of Claude's empty --tools allowlist: the tool is not in the session at all. --sandbox read-only stays behind it as the second line of defence, in the same position as the Claude lane's denylist.

One difference remains. Codex has no system prompt, so the plugin folds the character's instructions into the single input it accepts, above the conversation and never below it. On this lane the instruction hierarchy is a convention in the text, not a privileged channel.

The prompt reaches it on stdin here too (codex exec -), for the same ceiling reason.

The plugin

Two rules govern it, and each cost something to learn:

  • Never select a process by image name. The machine may run other claude and node processes that belong to you or to something else. Only the PID the plugin spawned is touched.
  • Never leave an orphan. If the game crashes, Unload never runs, so every child is put in a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — Windows kills it when the game process dies, however it dies.

It is now a hard dependency of the CLI lanes, and the failure mode is blunt. The mod's scripts declare one native class (AiNpcCli), and RED4ext refuses to start the game at all when a declared native class was registered by nobody — not the mod, the game. So a DLL that is missing or rejected on a patch day stops the game booting. That risk is accepted for this version and recorded, with the design that removes it, in docs/ARCHITECTURE.md -- "What the plugin costs on a patch day".

Build it with plugin\build.ps1 (needs Visual Studio with the C++ toolset; no CMake — it is a handful of translation units against a header-only SDK, compiled by cl.exe). Fetch the SDK once with:

git clone --depth 1 https://github.com/WopsS/RED4ext.SDK.git vendor\RED4ext.SDK

Its own offline suite is plugin\test\run.ps1, and tools\test.ps1 runs it too.

A character writes text and nothing else. It cannot read a file, run a command, or reach the network, because those tools are not in the session at all — not forbidden, absent.

Cost. The CLI still ships a harness prefix on every call, but a much smaller one than it used to. Measured 2026-08-24 on the same prompt and model, prompt tokens including cache:

tool restriction prompt tokens
--disallowed-tools alone (what this mod shipped before) 15691
--tools "" 3600

The tool schemas were still being sent under the denylist. That prefix is cached for an hour, so the first message of a session pays the cache write and the rest are cheap — but it is billed against the same subscription quota as your coding work.

Controls

Open the phone (contacts view), then:

Key Action
T open the AI chat for the hovered contact
Left click start typing
Enter send
Mouse wheel scroll history
Z undo last exchange — Debug Mode only; without it the key does nothing and the hint is not drawn
R reset the conversation
C close and go back to the contact list

Supported contacts

Panam, Judy, River, Kerry, Songbird, Rogue, Viktor, Takemura, Jackie (pre- and post-heist), and Mr. Studd (stud).

jackie_dead is the number after the heist, and nobody answers it: it returns one recorded line — This number is no longer in service. — to every message, in the language the game is running in. No prompt is built and no model is asked.

These are the built-ins, compiled in. Each is one file in src\r6\scripts\ai_npc\cast\ and one line in AiNpcBuiltinCast, plus its id in AiNpcGetAllContactIds. lint.ps1 fails if those lists drift apart, and the store creates the conversation entry on first use.

You do not have to recompile to change or add a character. See below.

Customising characters and prompts

Every prompt fragment can be overridden from <game>\r6\storages\AiNpc\ without touching the scripts, and contacts that belong to other mods can be given a voice there too:

File Holds
prompts.json world background, mechanics, guidelines, tone tiers, per-language text
characters.user.json your own character sheets and overrides — never overwritten
characters.<mod>.json dropped in by another mod
characters.example.json rewritten every launch, never read: the live schema reference
facts.<mod>.json quest facts to watch, so a character reacts to another mod's content
facts.example.json rewritten every launch, never read: the live schema for the above
config-report.json written every launch: what loaded, what was rejected and why

An absent or empty key means no opinion — the shipped text is kept. A file is read onto a copy of the character's sheet, key by key, so it overrides a character and never replaces them: a file that fails to load costs its own overrides and nothing else. The cast itself is compiled in (src/r6/scripts/ai_npc/cast/, one file per character), so it cannot fail to load at all.

Because the compiler no longer checks this text, everything is validated at start instead: unparseable files, unknown keys, unknown variant conditions, and action tags no parser handles are all reported to config-report.json and to the RED4ext log through FTLogError — visible whether or not the in-game logging toggle is on. Read that report first when a character does not behave the way its file says.

Extending from another mod

docs/API.md is the integration guide. In short: another mod gives one of its own contacts an AI voice either by dropping a characters.<mod>.json file — which creates no compile-time dependency on ai_npc, so it is simply ignored when ai_npc is absent — or, when the contact needs live game state, by subclassing AiNpcContactProvider and calling AiNpcRegisterContact.

Registration is dynamic: nothing needs to exist at launch, a provider can be registered and unregistered mid-session, and the phone picks the change up the next time its contact list is built. AiNpcContactHash derives the stable Int32 that Phone Extension Framework wants for a custom contact.

Project layout

src/r6/scripts/ai_npc/
  AiNpcHistory.reds           pure history model: append, trim, undo, transcript, legacy import
  AiNpcMemory.reds            pure memory model: what the history becomes when it leaves the window
  AiNpcMemoryService.reds     the thinking lane: compacts a batch into a memory, invisibly
  AiNpcLlm.reds               the transport both lanes share: url, model, headers, chat body, timeout
  AiNpcWatchdog.reds          the clock both lanes arm: a request that never comes back still ends
  AiNpcJournal.reds           pure journal model: operations, replay, branch naming
  AiNpcConversationStore.reds journal files, session lifecycle, the savegame pointer, migrations
  AiNpcResponses.reds         defensive readers for LLM API responses
  tests/                      runtime assertions over the pure modules, one file per subject
                              (count reported by test.ps1; the whole folder is dropped from a
                              release build, see Packaging). AiNpcTestSuite.reds in there is
                              nothing but `module AiNpc.TestSuite`: the marker AiNpcSelfTest.reds
                              asks about, so the assertions live in `module AiNpc` and stay
                              unexported. It leaves with the folder, never alone
  AiNpcStorage.reds       settings.json, created with defaults on first run, written back on edit
  AiNpcSetup.reds         the configuration surface CET talks to: describe, change, test, apply preset
  AiNpcInstallPreset.reds what the FOMOD installer chose; one variant per provider in the archive
  AiNpcConfig.reds        loads + validates prompts.json / characters.*.json, writes the report
  api/                    THE public surface: everything another mod may call. Nothing outside
                          this folder is part of the deal -- see docs/API.md. AiNpcApi (the
                          free functions), AiNpcClient (the handle and the two doors into it),
                          AiNpcContactProvider / AiNpcExtension / AiNpcObserver (what a mod
                          subclasses), AiNpcMessage, AiNpcPromptOverrides, AiNpcContactTags,
                          AiNpcActionHandler, AiNpcScriptedReply, AiNpcJournalApi
  AiNpcContactRegistry.reds  which provider answers for a contact id, and the contact hashes
  AiNpcFactBridge.reds    the quest-fact bridge: another mod's fact in, an acknowledgement out
  AiNpcRomanceExtension.reds  ai_npc's own romance rubric, contributed through ai_npc's own
                          extension API: <relationship> is the psychology either way, this
                          adds what the romance changes -- or states the refusal when it
                          has not happened
  AiNpcUtilities.reds     reaching the mod's own systems, and the settings asked for by name
  AiNpcChatDoor.reds      the one place a conversation opens and closes, for BOTH surfaces:
                          the session's shown contact, the two listener events, and the
                          nudge to the memory service. A surface announcing for itself is
                          what left AGENT LINK silent
  AiNpcSystem.reds        ScriptableService: the phone chat's MODEL -- open/closed, typing,
                          key bindings, and the two vanilla phone controllers it holds. The
                          open thread is the session's, not a field here
  AiNpcModSettings.reds   the ten fields the Mod Settings menu binds to, read only through the
                          named accessors in AiNpcUtilities.reds
  AiNpcEconomy.reds       the eddie grant; AiNpcTransferLedger.reds is what each contact has
                          already released, and the bound on it
  AiNpcPendingContextService.reds  what mods have told a character and it has not heard yet
  AiNpcPhoneState.reds    where the player is in the phone and HOW they got there: the four
                          screens, the edges between them, and nothing else may decide one
  AiNpcPhoneRenderer.reds the phone chat's PAINTER: every widget handle in the mod, and the
                          AiNpcChatView it registers while the chat is on screen
  AiNpcPhoneWidgets.reds  one validated walk of the vanilla contact list, or nothing --
                          plus the "T" badge grafted onto a row, which is the same walk
  AiNpcHttp.reds          the speaking lane: one request in flight, its failures, its repair
  AiNpcContextData.reds   quest-state -> prompt context lookups
  AiNpcWeather.reds       the sky, as one word for the prompt
  AiNpcFactEvent.reds     another mod's sentence, framed as the world reporting
  AiNpcHooks.reds         @wrapMethod hooks into the vanilla phone UI. Reports facts to
                          AiNpcSystem and decides nothing; tools\lint.ps1 enforces it
  AiNpcTerminal*.reds     AGENT LINK: the same conversations on a Night City computer, as a
                          BrowserExtension site. Site, chat, style, text, sort, input field

src/archive/pc/mod/
  ai_npc.archive          one file: the AGENT LINK icon. Built by tools\build-archive.ps1
                          from src\archive\, never edited by hand -- docs/branding/

docs/
  ARCHITECTURE.md         the map of the running system, and the invariants no compiler sees
  API.md                  integration guide for other mods: JSON contacts + script providers
  MEMORY.md               design of the conversation memory: model, invariant, lanes, ordering
  VIEW_ARCHITECTURE.md    the rules the two chat surfaces obey, and what lint keeps
  PROMPT_BUDGET.md        what each prompt section costs, and the toggles that let the player spend it
  TOKEN_BUDGET.md         the daily cap: where "today" comes from, and what it refuses
  MODEL_COSTS.md          what an hour of play costs, per model
  CHARACTER_RULES.md      how an action criterion is written; CHARACTER_PROCESS.md is the procedure
  ARC_FACTS.md            the quest facts that say how a character's arc ended
  DISTRIBUTION.md         which lanes may be shipped, and to whom
  CREDITS.md              where the mod comes from, and what it owes
  PLAN_*.md               work briefed and not yet done
  branding/               the AGENT LINK icon, the AI NPC Nexus image, and how a .png becomes
                          an icon on a terminal

tools/
  lint.ps1                static checks (runs in under a second)
  compile-check.ps1       isolated redscript type-check against the vanilla bundle
  test.ps1                runs lint + compile-check + reports the last runtime results
  deploy.ps1              type-checks, then copies src/ into the Vortex staging folder and the game
  package.ps1             builds a distributable zip in dist/ (runs build-archive.ps1 first)
  build-archive.ps1       packs src\archive\ into src\archive\pc\mod\ai_npc.archive
  set-version.ps1         writes the version in the two places that state it; run with no
                          argument it prints them. lint.ps1 is what refuses a disagreement
  make-icon.py            draws the AGENT LINK icon and the Nexus image into docs/branding/
  inkatlas/, archive/     the two C# helpers those two use (WolvenKit 8.20 DLLs, dotnet 8)
  prompt/                 rebuilds offline the exact prompt the mod sends, and compares it
                          byte for byte to a capture. Run by test.ps1 -- see Tests

What is not here. The bench that chose the shipped defaults lives in a separate, private repository, ai_npc_lab, beside this one: provider runs and their replies, the explicitness measurements, the vocabulary extraction, the conversation-journal readers, and the captured play samples they read. Documents here cite it by name where a number came from one of those runs. Nothing in this repository needs it -- the mod builds, packages and passes its tests without it. The prompt regression suite stayed here on purpose, because it is a check on the mod rather than an experiment.

Two names, on purpose. The site is called AGENT LINK in the game -- an Agent is the pocket assistant a citizen carries, and this is its companion on a desk. The mod is called ai_npc on Nexus and on disk. Nothing the player reads says "ai_npc": that name describes what the thing is made of, which no application in Night City would announce.

Namespacing

REDscript without a module declaration puts everything in one flat global namespace: two mods that each declare class HttpRequestSystem will not compile together. Everything here is therefore prefixed AiNpc* and lives in module AiNpc — anything not marked public is invisible outside the mod, and ModuleExists("AiNpc") gives third-party mods a compile-time way to depend on this one optionally.

The phone is the part no module boundary can protect. The hooks are @wrapMethod on vanilla widgets and the key handling is raw, so both are shared surfaces by construction: run only one mod that texts through the phone. Two of them compile fine and then answer the same keypress.

Conversation storage

History is one ordered list of messages per contact, each tagged with its author, and it is tied to your savegames: reload an earlier save and the chat rewinds with it; start a new game and the characters have never met you.

The text itself stays out of the savegame. Instead the save carries a pointer into an append-only log on disk — one file per branch, one JSON operation per line:

{"h":3,"b":"b3","from":"b1:100"}
{"n":101,"c":"judy","o":"a","p":true,"t":"hey","g":90000}
{"n":102,"c":"judy","o":"a","p":false,"t":"hey yourself","g":90180}

"g" is the in-game clock when the message was written, in absolute seconds — so a reload knows not just the order of the messages but when each one was sent, and how long a character was left waiting. It is absent on anything written before the field existed, and on a seeded history; readers treat a missing "g" as "unknown", never as time zero.

What the character sees. Not a timestamp per line — that would spend context repeating something that rarely changes, and models copy the format straight into their reply. The transcript marks the breaks instead, on their own line, so the V: / Judy: grammar the stop sequences rely on is never touched:

V: t'es où ?
Judy: bar, comme d'hab
(2 days later, 3:00pm)
V: désolé, j'ai disparu
(7 hours later, 10:00pm)

The scale picks the wording: under 30 minutes nothing at all (an hour of chat with a message every ten minutes reads as one scene), then (2 hours later), then the clock joins in past six hours, and past a day it counts days — 195 hours later is not something anyone reads. The last marker is the one that matters most: the silence between the final stored message and the line V is sending right now. A history with no stored times renders exactly as it did before any of this existed.

Loading is a replay of every operation up to the saved sequence number. The savegame's share of this is two integers — the branch id and that sequence number — kept current on every message, so whatever moment you save at is the moment you come back to.

Branches. Save at 100, play on to 150, reload: those later numbers belong to a timeline this save is no longer on. The next message therefore forks — a new branch file opens with a snapshot of the replayed state, and the two timelines never share a number again. Branch ids come from a counter that is never decremented, so a deleted branch's id is never handed out twice: a save pointing into a branch that is gone loads an empty history, never someone else's.

Only the last 24 branches are kept. Nothing can enumerate your savegames from inside the game, so no branch can ever be proven dead — the guarantee is one-sided on purpose: a very old save may find its history gone, but no save ever gets the wrong one.

Memory. Past sixteen turns a conversation no longer sends everything it holds. The oldest batch is compacted, out of character and out of sight, into a small structured memory — established facts, still-open threads, one line of tone — which travels in the same journal entry that evicts the messages it absorbed, so a reload can never land between the two. What the character still has verbatim is the last six turns; what it has before that, it remembers the way a person does, in less and less detail. The design, the invariant it rests on, and why the summarising request is kept on a separate lane from the one the player is waiting for, are in docs/MEMORY.md.

Two settings, in Mod Settings → AI NPC → Memory: the feature itself (on by default) and Memory Size, how many consolidated facts a character keeps in front of it — 20 by default, 8 to 40. Raising it only costs tokens, about 30 per fact on every request. Lowering it drops the oldest facts out of the prompt at the next compaction: they stay in the archive on disk, but the character stops knowing them. The menu says exactly that, because the slider is the only place a player can see the trade.

AiNpcConversationStore owns all of this and is the single source of truth — nothing else caches a copy. AiNpcHistory.reds (append, trim, undo, transcript) and AiNpcJournal.reds (operations, replay) hold the model as pure functions: no game API, no disk, no globals. The live path and the replay call the same AiNpcJournalApply, so "what the session did" and "what a reload rebuilds" cannot drift apart. That purity is what makes them unit-testable, and lint.ps1 enforces it.

Upgrading. An existing conversations.json (schema v2, or the older "a|b|"-joined format) is converted once into a read-only seed branch, and backed up as conversations.v2.json. Saves made before the upgrade adopt that seed, so nothing is lost; each forks its own branch on its next message. A brand new game is never offered it — which is what stops a fresh V from remembering the previous playthrough.

Reading and repairing the journal offline

ai_npc_lab\journal\journal.py replays the same model in Python, so the branch files can be read — and, when something goes wrong, rewritten — without launching the game.

python ai_npc_lab\journal\journal.py list                                  # every branch, and the pointers seen in gamelog.log
python ai_npc_lab\journal\journal.py show b14:467 --only MiraContact --tail 10
python ai_npc_lab\journal\journal.py import --from b14:467 --into b14 --apply   # fallback; prefer the setting below

list is the diagnostic: one line per branch with its parent, its head, how many conversations and messages it replays to, and — read from gamelog.log, the only place a savegame's pointer is ever visible from outside the save — which pointer each recent session actually restored. That is how a save is matched to a branch.

A contact that stopped answering: checking the stored text

tools\utf8.py scans everything the mod has written to disk for malformed UTF-8, and drops it on request.

python tools\utf8.py                # report on the game's storage
python tools\utf8.py --repair       # drop the bad bytes, keeping a backup (game closed)

The symptom it answers is specific and misleading: one contact stops answering, on the phone and on the terminal alike, while the others are fine. A stored message holding a malformed byte makes every later request for that contact malformed too — the whole conversation is re-read into each one — and such a request never leaves the client. What comes back is status 0, which looks exactly like a network or a TLS problem and is neither.

The mod no longer produces such a byte (text a player types is measured in characters, not bytes — AiNpcUtf8.reds), so this is for a history written before that fix.

Importing a lost history

The situation this answers: a save has been lost — corrupted by another mod, say — and play has to resume from an earlier one, which rewinds every conversation with it.

The pointer lives inside the savegame, so nothing outside the game can aim an old save at a newer history. The mod therefore does it from inside. Put the pointer you want in settings.json, load the save you are keeping, and the store adopts it:

"importConversationsFrom": "b14:467"

settings.json is re-read on every save load, so no relaunch is needed: edit the file, load the save from the main menu, and delete the key again afterwards — all in one sitting.

journal.py list is where that pointer comes from — it prints, from gamelog.log, which pointer each recent session restored, so the lost save is identified by the conversation counts it loaded with. "b14" with no number means "as far as that branch goes".

What happens on the next load is a replay of that pointer followed immediately by a fork: the save comes out on a branch of its own, seeded with the imported conversations. Forking rather than adopting the source pointer is the safety of it — two saves sharing a branch is the bug this journal exists to prevent — and it means the source file is never written to, so the import can be repeated, aimed elsewhere, or undone by loading a save that predates it. journal.index.json records the provenance as "from": "b14:467".

Two persistent fields remember what a savegame has imported, so a key left in place cannot re-import over live conversations on the next load. Every other save you open still will, though — which is why the log says so twice, and why the key is meant to be deleted once it has run. Confirm in gamelog.log:

[ai_npc]: imported 19 conversation(s) from b14:467 into b16:19.

From CET, when you do not yet know which pointer you want — which is the normal case, because the pointer is the thing you are looking for. The mod ships a CET window (bin\x64\plugins\cyber_engine_tweaks\mods\ai_npc_debug\, installed by the archive like everything else; ignored where CET is absent). Open the overlay, tab Journal — the other tab is the setup window — and it asks the three questions in order:

  1. which branch was the lost playthrough? — the table lists every branch with its parent and head; selecting one replays it and shows each contact, its message count, and the last thing said in it. You recognise a playthrough by reading it, not by counting it.
  2. how far back? — the sequence field is prefilled with the branch head; Preview re-reads the branch as it stood at any earlier number.
  3. restore it onto the game being played — a confirmation checkbox names the pointer that is about to replace this savegame's history, and after the import an Undo button offers the pointer you were on before, because nothing else remembers it.

Nothing is overwritten on disk at any point: restoring replays the source and forks. The same functions are on the console — GetMod("ai_npc_debug").status(), .branches(), .detail("b14:96"), .restore("b14:467") — and on the store itself for anyone who would rather not install the CET mod:

local s = Game.GetScriptableSystemsContainer():Get("AiNpc.AiNpcConversationStore")
print(s:DescribeBranches())
print(s:DescribeBranchDetail("b14:467"))
print(s:ImportFromPointer("b14:467"))

Bare AiNpcJournalStatus() will not work: CET has no such Lua global. The functions in AiNpcJournalApi.reds are for redscript callers — another mod taking ai_npc as an optional dependency — and are documented with the store's own methods in docs/API.md.

Same work as the settings key, same fork, same log line — it simply happens now instead of at the next load. Close the phone first; the import refuses while a conversation is on screen.

The offline alternative. journal.py import --from b14:467 --into b14 --apply rewrites the branch file itself so that every save pointing at it replays the imported state. It needs no game launch and can cherry-pick contacts (--only MiraContact judy), but it replaces a branch file — the old one is kept as journal.b<N>.jsonl.bak — and it affects every save on that branch rather than the one you chose. Prefer the setting; keep this for when the game cannot be launched, or when only part of a history should come across. Quit the game to desktop first either way: the store appends to these files live.

Tests

powershell -File tools\test.ps1

Three stages. The first two need no game launch:

  • lint.ps1 — static checks on the sources: no credentials committed, no duplicate top-level declarations, every [ACTION:...] tag advertised in a prompt is one the parser actually matches, every member of an enum the settings menu exposes has a display value, and AiNpcGetAllContactIds agrees with AiNpcGetContactId.

  • compile-check.ps1 — runs the real redscript compiler (engine\tools\scc.exe) against the vanilla script bundle plus only the declared dependencies, in a temp directory. It never touches the game's script cache. Because the dependency set is explicit, this also proves the mod does not silently rely on some unrelated mod you happen to have installed.

  • runtime self-tests — assertions over the pure modules, in src\r6\scripts\ai_npc\tests\. REDscript has no standalone runtime, so they execute inside the game (at storage init, cost is a few hundred string operations) and write r6\storages\AiNpc\test-results.json. test.ps1 reads that file back and reports it, including how stale it is. Failures are also logged as errors.

What none of this covers: widget hierarchy traversal, ScriptableSystem lifecycle, and real HTTP responses. Those still need manual play-testing.

Deploying while developing

powershell -File tools\deploy.ps1

Type-checks, then copies src\r6\scripts\ai_npc\*.reds into both the Vortex staging folder and the game — and deletes any .reds there that no longer exists in src.

Both targets matter. This mod is installed through Vortex, which keeps its own staging copy and hardlinks it into the game folder: writing only to the game works until Vortex next deploys or purges, then silently reverts; writing only to staging does nothing until Vortex deploys. A stale script left behind is worse than either: a renamed file that is still present declares the same symbols twice in the same module, which is a compile error that does not reproduce from source.

Adjust -GameDir / -StagingDir if your paths differ; -SkipChecks skips the type-check.

Then launch the game: it compiles the scripts and runs the self-tests, and tools\test.ps1 reports the result afterwards.

Packaging

powershell -File tools\package.ps1             # debug: the self-tests come with it
powershell -File tools\package.ps1 -Release    # release: what goes on Nexus

Two builds, and the difference is one folder. A -Release build drops src\r6\scripts\ai_npc\tests\ -- roughly 5000 lines of assertions the player never reads, compiled at every game start. The folder is the unit rather than a list of file names, so a test file added tomorrow cannot reach a player because nobody extended the list. It carries its own marker: AiNpcTestSuite.reds, the empty file whose only job is to declare the module AiNpcSelfTest.reds asks about, sits inside it and leaves with it. tools\package.ps1 checks what the folder cannot say by itself -- that the folder is there, and that the marker is in it, because a marker left outside would survive the release build and report "0 tests".

A debug build keeps both, so the suite runs at startup and writes r6\storages\AiNpc\test-results.json, which is what tools\test.ps1 reports on.

Release is the default on purpose: the build that leaves this machine is the one that must not need a flag to be correct. Nothing else differs between the two -- the seam is AiNpcSelfTest.reds, fifteen lines of @if(ModuleExists("AiNpc.Tests")) whose whole job is to make deleting the other file legal. Both shapes are type-checked by tools\test.ps1; compile-check.ps1 -WithoutTests checks the release one on its own.

Produces two archives in dist\:

File Use
ai_npc-<version>.zip archiving, and uploading to Nexus
ai_npc-<version>-debug.zip the same build with the self-tests, never uploaded
ai_npc.zip installing into Vortex -- whichever configuration was built last

The stable name is not cosmetic. Vortex derives a manually installed mod's identity from the archive file name — verified in its own state database, where this mod is recorded with archiveId: null and no version, modId, fileId or source attribute at all, only fileMD5, fileName and name. So ai_npc-0.6.2.zip and ai_npc-0.7.0.zip install as two unrelated mods, and Vortex never offers to replace one with the other: the "already installed" prompt keys off that derived identity, and two different names cannot collide. Installing ai_npc.zip every time makes successive builds collide on purpose, which is what produces the Replace prompt.

Nothing is lost by dropping the version from that file name: it is compiled into AiNpcVersion() and logged at startup, which is the only place it can be checked once the mod is installed.

-NoStableCopy skips the second archive.

The version comes from AiNpcVersion() in AiNpcUtilities.reds, which is the source of truth. Bump it there; the archive is named from it, and it is logged unconditionally at startup so an installed build can be identified from the log. Passing -Version is allowed only to assert what the sources already say — a mismatch fails the build rather than producing an archive whose name contradicts its contents.

That matters because nothing reads a version out of the archive after installation. Vortex identifies a mod by the MD5 of its archive against its meta server, or by Nexus download metadata; for a locally built zip it has neither. There is no in-archive manifest format that would change this, and Cyberpunk's REDmod info.json does not apply — it only covers mods deployed under mods\, while this one installs to r6\scripts and red4ext\plugins. Hence the stable archive name above; publishing on Nexus (real modId/fileId) is the other way out.

-SkipPlugin builds a scripts-only archive for the cloud providers.

Note: redscript sources cannot be shipped pre-compiled — the redscript compiler rebuilds final.redscripts at every game launch from all installed mods, and that bundle is specific to your game build. "Compiled mod" here means packaged for one-click install.

Credits and licence

The mod is MIT; the repository is not. The grant covers what ships to a player — src/, plugin/ and fomod/. The tooling in tools/, the documentation in docs/ and the raw asset sources in source/ are not licensed, and vendor/ is somebody else's submodule under its own terms. LICENSE states the scope, and docs/CREDITS.md is the full attribution.

This is not a fork. ai_npc was written from scratch. It is not a fork of Immersive Companion Generative Texting, of Generative Texting, or of anything else in that family, and it ships no line of their code and none of their prompts. It is not one more variant — it is a different mod that came from the same idea.

And it would not exist without them. Their mods were played long before a line of this one was written. That is where the idea came from: a character answering on the player's own in-game phone, quest state feeding what that character says, and jackie_dead — a contact who answers from the far side of their own death. Thank you to their authors for clearing the road.

Some things are kept on purpose, so a player arriving from those mods finds what they expect: the T key, and the contact identifiers — jackie, jackie_dead, panam, kerry_eurodyne, judy, songbird, river_ward, rogue, victor_vector, takemura.

What the licence does not cover. MIT covers the mod's own code. It does not cover the frameworks — each installed by the player under its own terms — nor the game's characters and setting, which are CD Projekt Red's.

ai_npc is an unofficial fan modification, not affiliated with or endorsed by CD Projekt Red.

About

Unscripted conversation with Cyberpunk 2077 characters, on V's own phone and on Night City terminals. REDscript, RED4ext, and the LLM provider of your choice.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages