@dsh-agentic-roleplay/state
The persistent session-state and memory service for DSH Agentic Roleplay, maintained by Firefox2100.
Most users should install @dsh-agentic-roleplay/all. Install this package separately when another DSH plugin needs the roleplay database, repositories, recall engine, or model-facing record tools without the complete roleplay interface.
A DeepSeek Harness plugin that keeps the state of roleplay sessions: one SQLite
database per session, reached through repositories over canonical models. It
does no orchestration and has no UI, so it can be installed on its own to get a
roleplay database and state management without the rest of the roleplay bundle.
Other plugins use it through the roleplayState service. Model-facing tools
are included and can be enabled through its configuration.
Requires DSH 0.2 and Node 22.19 or later (it uses the built-in node:sqlite).
dsh plugin --profile <name> add @dsh-agentic-roleplay/state
Using it from another plugin
import type {} from '@dsh-agentic-roleplay/state' // types `ctx.roleplayState`
import type { Context } from '@deepseek-ai/cordis'
export const inject = ['roleplayState']
export function apply(ctx: Context) {
// Creates sessions/<uuid>/session.sqlite and opens it.
const session = ctx.roleplayState.createSession('/path/to/sessions', {
name: 'First night',
world: { id: 'blackwater-observatory', name: 'The Blackwater Observatory' },
})
session.transaction(() => {
const mayor = session.characters.add({ name: 'Eleanor Graves', /* … */ control: 'agent' })
session.characterStates.create(mayor.id, { publicState: '…', innerState: '…', activity: '…' })
session.intents.add(mayor.id, { type: 'agenda', name: '…', /* … */ })
})
session.characters.findByName('the mayor') // by name or alias, ignoring case
session.characterStates.update(1, { activity: 'looking for the key' }, { expectedVersion: 3 })
}
The service creates and opens sessions under a sessions folder you give it,
shares one connection per session, and closes them all when the plugin unloads:
| Method | Does |
|---|
createSession(sessionsDir, init) | Creates the session folder and database and returns its repositories. The id is generated (UUID v4) unless given. Nothing is left behind if it fails. |
openSession(sessionsDir, id) | Returns the repositories of an existing session. |
listSessions(sessionsDir) | The metadata of every session under the folder (name, world, creation time, last played time, turns). A session that cannot be read is reported in problems, not thrown. |
closeSession(sessionsDir, id) | Releases the connection; opening again reconnects. |
deleteSession(sessionsDir, id) | Closes the session and removes its folder. |
Session ids become folder names, so only a UUID v4 is accepted. The package also
exports the same operations as plain functions (createSession, openSession,
createSessionStore, …) for code that does not run inside DSH, such as tests.
The records API and the model-facing tools
The package owns how a story is remembered, not only where it is stored:
session.edit(edit) changes the story's records (character states, events with who took each in, beliefs, threads) together or not at all. It checks the whole edit first and throws InvalidEditError naming every problem, so a model's mistakes can be reported back to it. parseRecordsEdit(json) reads the wire format models write (snake_case) and recordsEditSchema({ characters, changes }) is its JSON schema, for a model's structured output or a tool's parameters.
session.records(scope) reads the records back in the same wire format.
session.recall({ characterId, situation, turn, policy }) is what a character is reminded of: events it took in and things it believes, by who the situation mentions, shared words and tags, importance and story-time recency; with policy.flaky the older and less important are told as faded or vague, or are gone. Age is elapsed story time from session.clock, never turn count; an unimportant event has a six-story-day half-life. It counts what it brings back as recalled, which makes it last longer.
session.recallStored(query, { queryVector? }) adds the session's search index to the cues: texts that match the situation by words and, with the vector of the situation, by meaning. session.searchEvents({ text, vector?, fromTurn, toTurn, minImportance, limit }) finds what happened by the same two searches, merged by reciprocal rank.
session.recallRanked(query, signal, { queryVector? }) is the call for anything that reminds a character of something: recall, ranked by meaning when a plugin has registered a ranker with roleplayState.rankMemories(ranker) (an embedding model, a reranker; see docs/capabilities.md). session.recallCandidates(query) gives what could be recalled, with the text to match, for a ranker. With explain: true in the query each recalled thing carries why: the cues of the moment (people named, words shared, place), what each search scored it, how much it matters, how long ago it was, how much of it is left in mind and what it was ranked by.
session.knowledge is the closed-world awareness graph for locations, items and directed exits (learn, knows, get, list, forget). Directional relationships are the corresponding character-to-character edges. Awareness grants identity only, not current state or whereabouts. session.planKnownMove plans through only a character's known places and exits; planMove remains the authoritative world planner.
session.factions, , , and store persistent organisations separately from characters that act in scenes. Membership is many-to-many; relationships involving factions are directional and double as closed-world awareness edges.
Tools registered with the DSH tool registry (defineTool, typed output, honoring the call's signal), resolved through that binding: list_characters, get_character, search_events, recall_memories, list_threads, and, only when the plugin is configured with writeTools: true, update_character_state, record_event, remember, open_thread, resolve_thread. They fail with a plain message for an agent that works in no roleplay session. Characters and other one-shot sub-agents see none of them (they are started with no tools).
Configuration
| Field | Default | Meaning |
|---|
journalMode | delete | delete keeps each session in a single file that can be copied; wal allows readers during writes. |
writeTools | false | Also register the model-facing tools that change the records. Leave off where another agent is responsible for them. |
Layers
| Layer | What it is |
|---|
| Canonical model | The types in dsh-roleplay-model (re-exported here): Character, CharacterState, CharacterIntent, SessionMetadata, … |
| Repositories | MetadataRepository, CharacterRepository, CharacterStateRepository, IntentRepository: the only access to the data. They take and return canonical models and throw the errors below. |
| SQLite implementation | Maps rows to models and back; the only place that knows tables and SQL. |
| Driver | SqlDriver / SqlDatabase: the few operations the implementation needs (exec, run, get, all, transaction). nodeSqliteDriver is built on node:sqlite. |
Everything is synchronous, as the embedded SQLite libraries are.
This is an independent community project, not an official DSH package. Documentation · Source · Issues · GPL-3.0-only
The session
A session is the folder sessions/<uuid>/ with its database session.sqlite.
The database is the only file the state needs; with the default journal mode it
stays a single file that can be copied or archived.
The world itself is not stored. metadata keeps a world reference: the
world's stable id (the id in its world.json, not a database id), its name,
and optionally a version, a fingerprint of the content the session was started
from, its source, and free-form extra fields. It is saved as one document, so
provenance can grow without a schema change.
The metadata also carries what a list of sessions shows: the session's name
(chosen when it is created), its creation time, the time of the last play
(absent until a turn has been played) and the number of turns played. Whatever
plays a turn calls metadata.recordTurn(), which counts it and sets the time.
Characters have session-local integer ids. source_id keeps the id the
character had in the world, so a session can be traced back to its world;
characters created during play have none.
Schema (version 1)
| Table | Holds |
|---|
metadata | Key/value rows: session.id, session.name, session.created_at, session.last_played_at, session.turns, world (JSON), plus free-form namespaced entries. |
characters | The definition: name, age, gender, pronouns, kind, tier, role, summary, personality, background, appearance, speech, and control (player or agent, chosen for the session). source_id is unique. |
character_aliases | One row per alias, in order, with a case-folded key for lookup. |
character_state | The state that changes in play: public state, inner state, activity, version, updated_at. One row per character. |
character_emotion | Optional baseline and immediate valence/arousal/dominance of a state. |
intents | A character's intents: type, name, description, status, horizon, priority, urgency, plan, open threads, constraints, deadline. |
relationships, consolidation_items | How a character stands towards another (a profile per direction: label, summary, familiarity, trust, affinity, tension) and the characters and places made up in play that still have to be fitted into the world. lintWorld reports what is inconsistent. The edit can make characters, places and ways when it is the authoring variant (recordsEditSchema({ authoring: true })). |
entity_knowledge | Explicit character-to-location/item/exit awareness: the first learned turn and optional last directly observed turn. Absence means unknown; current facts remain in state, perception and memory. |
factions, faction_aliases, faction_state, faction_memberships, faction_relationships, faction_intents | Organisations, their institutional state and strategy, many-to-many membership, and directional relationships involving a faction. |
slots, items, item_holdings, openers, event_items | Things (one kind for items, equipment and containers: quantity, condition, notable, hidden, the slot it is worn in, a container state, an owner), where each is (a place, a character's hands or a slot, or inside a container item), the places on the body something can be worn (with defaults, data per session), what opens each lock (on items and exits), and which events are about which items. does what a character does with a thing (take, drop, give, put, wear, remove, open, close, lock, unlock, reveal) by the rules of the story and says every reason when it cannot. |
Events are the ground truth; perceptions (which stay revisable: updatePerception, addPerception), memories and summaries are derived from them and can be rebuilt. The repositories are events, memories, threads, summaries and pendingCommits next to the ones for characters.
Deleting a character deletes everything that belongs to it. The tables are
STRICT, enumerations and ranges are CHECK constraints, and foreign keys are
enforced. The file records its schema version in user_version and carries an
application id, so a file that is not a session database is refused.
Changing the schema
Migrations are applied in order when a session is opened and are never edited
once released: a change is a new entry in src/sqlite/schema.ts, and a test
pins the migrations of each release. A database from a newer version is refused
rather than damaged.
Before a file that is behind the latest schema is upgraded, it must pass SQLite's
quick integrity check (a damaged file is refused with CorruptDataError, untouched)
and a copy of it as it was is written to migration-backups/schema-<version>.sqlite
beside the database, once per old version. migrationBackupPath(location, version)
says where.
Errors
| Error | When |
|---|
NotFoundError | A row, character or session file does not exist. |
ConflictError | A repeated source_id, a state created twice, a stale expectedVersion, or creating a session where one exists. |
InvalidValueError | A value is outside what the schema allows. |
SchemaError | The file is not a session database, or comes from a newer version. |
CorruptDataError | Stored data cannot be read back as a model. |