- TypeScript 67.2%
- JavaScript 27.6%
- CSS 3.7%
- HTML 0.9%
- Mustache 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .dakuvi | ||
| docs | ||
| eslint-rules | ||
| plugins | ||
| src | ||
| test | ||
| ui | ||
| .gitignore | ||
| esbuild.config.mjs | ||
| eslint.config.js | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
Dakuvi
A web-based AI harness: plugin-first, provider-agnostic, permission-gated, and recoverable. The core is thin - it owns the turn engine, session/conversation state, the permission gate, and the tool registry/pipeline. Everything behavioral (tools, model providers, persistence, file-state versioning) is a plugin.
Why Dakuvi?
Dakuvi was originally just a random name. We wanted something short, pronounceable, distinctive, and not already associated with an AI project, so we made one up and kept it.
Only later did we discover that “dakuvi” is an actual word in Ndamba, a Bantu language spoken in Tanzania. In the Ndamba Bible translation, it appears in Luke 21:11, in a passage describing earthquakes, famine, pestilence, and frightening signs.
That was an accidental match, but it seemed strangely appropriate for an LLM harness. Dakuvi was not named after the passage, and it does not have a deliberately chosen biblical meaning. We simply happened to pick a random name that, somewhere in the world, means something associated with trouble.
So: Dakuvi was a random name that accidentally turned out to mean something fitting.
Built with TypeScript + Node. Two runtime dependencies - mustache (the Web
UI's item/session-list templates are rendered from mustache at runtime) and
i18next (the Web UI's translation runtime); the backend uses only Node
built-ins and a hand-rolled zero-dependency WebSocket server. typescript,
@types/node, @types/mustache, esbuild, eslint, and playwright (used
only by the headless UI e2e) are dev-only.
Requirements
- Node.js >= 24 (global
fetch,WebSocket, ESM, built-innode:sqlite). - Git on your
PATH- only if you use thegitchange-management plugin (commit-per-turn, file-state undo/redo, fork branches). The core and all tools run fine without it.
Quick Start
npm install # runtime deps (mustache, i18next) + dev deps
npm run build # esbuild -> dist/
node dist/main.js
dist/ contains main.js (the single-file backend bundle, also the shared module every
plugin imports via the bare @dakuvi/core specifier), ui/ (the Web UI -
index.html, app.js (a single-file IIFE bundle of ui/app.ts + the ui/src/*
modules it imports), and css/main.css bundled from ui/css/), and
plugins/<group>/<name>.js (one self-contained bundle per plugin, group layout preserved;
loaded at runtime from dist/plugins/, config pluginsDir, scanned recursively).
On success it prints:
Dakuvi ready
UI: http://127.0.0.1:8765/
Plugins: sqlite-store, bash-tool, context-compaction, debug, echo-provider, edit-tool, git, openai-provider, read-tool, reference-plugin, test-provider, write-tool
Tools: bash, edit, read, write
Open http://127.0.0.1:8765/ in a browser to use the chat UI (create a session, send
prompts, approve ask-gated tool calls, /undo, /redo, fork). Stop with Ctrl+C
(SIGINT/SIGTERM) for a graceful shutdown.
Configuration
Settings resolve across four layers. Every layer is referred to by its name, never by a number (the old L0..L4 numbering is dropped):
| Layer | Name | Storage | Owner |
|---|---|---|---|
| Hardcoded defaults | defaults |
in code (fillFallbacks()) |
the server process |
| Global ("system") config | global |
~/.config/dakuvi/config.json (optional; skipped if absent) |
the server process |
| Project config | project |
<workdir>/.dakuvi/config.json (optional; skipped if absent) |
the Project instance of that project |
| Session config | config |
the config record on the session row |
per-session (SessionManager, in the store) |
A read walks config -> project -> global -> defaults and the first
layer that carries the key wins; an entry that is explicitly null counts as
"not set here" and falls through. There is no deep merge and no list-append
- each layer keeps its own record and the lookup is per key (a layer only
shadows a key it actually carries; missing files are silently ignored and no
file is required, the
defaultslayer always applies).
Ownership is strict: defaults and global belong to the server process
(one copy, shared by every project it hosts), project belongs to the
Project instance of that one project, and the session record is session
state that resolves in that project's SessionManager. The Project instance never
reads session data; a config read resolves inside the owning project's instance
only, so a value set for one project or one session (e.g. its
maxStepsPerTurn) applies to that one only and never crosses into another
project or session. The write paths are the process-level set of the defaults
and global layers plus the Project instance's own setConfig for its
project layer, and
setSessionConfig(sessionId, key, value) for the session record. All that,
the look-up order and the wire protocol are specified in
docs/03-02-session-model.md (Session <> Config).
Key fields:
| Field | Meaning | Default |
|---|---|---|
host, port |
Bind address for HTTP + WebSocket UI | 127.0.0.1, 8765 |
defaultModel |
Default model selection for new sessions, as provider/modelname |
- |
providers.openai.baseUrl |
OpenAI-compatible endpoint (LM Studio / vLLM / Ollama / real OpenAI) | http://localhost:1234/v1 |
providers.openai.model |
Model name for the openai provider |
- |
maxStepsPerTurn |
Step (model + tool-call) budget per turn; a turn pauses when exceeded and is resumed with /continue. Resolves per session: the session's config record, then the project/global/defaults layers |
50 |
permissions |
Per-tool allow/ask/deny rules (globs), default is ask |
see file |
git.name, git.email |
Identity used by the git plugin's commits | - |
pluginsDir, dbPath, uiDir |
Where the plugin bundles, the store DB file, and the UI assets are found | dist/plugins (scanned recursively; plugin files live in group subdirectories), dist/plugins, dist/ui |
store |
Name of the persistence backend to activate (key into the StoreRegistry). Persistence is a store plugin, not a core dependency - the shipped default is the sqlite store plugin. |
sqlite |
storePlugin |
Compiled module path of the store plugin to load before the regular plugin scan (explicit path, not discovered by the scan). The plugin self-registers under its name. |
<pluginsDir>/storages/sqlite-store.js (i.e. the bundled sqlite plugin) |
debug |
LLM-response debug logging (debug plugin): enabled turns it on at startup (also toggle at runtime with `/debug on |
off); file` overrides the log path. Off by default. |
A commented-out example of the step-budget override (config files are strict JSON - no comments - so this shows the key you would set on its own line):
{
// uncomment and edit to override the per-turn step budget (default 50)
"maxStepsPerTurn": 50
}
Environment overrides: DAKUVI_HOST, DAKUVI_PORT, DAKUVI_DEFAULT_MODEL.
The OpenAI provider also honors OPENAI_BASE_URL, OPENAI_API_KEY, and
DAKUVI_OPENAI_MODEL.
Working with multiple projects
One running server hosts N projects at the same time, each with its own
sessions. A project is a working directory plus its Project instance, which
owns the project layer, the rootDir, the store handle and the
SessionManager of that project. The server process keeps defaults, global,
the plugin scan and the shared tool/provider registries — those are boot-time
decisions of the one process and are not copied per project.
Dakuvi (main server, process level, one)
├- process config defaults (fillFallbacks) + global file
├- plugin scan / load + tool + provider registry (one set, shared)
├- DakuviServer (one listener) -> project selector + per-WS project pin
└- ProjectRegistry: projectKey -> Project (instances created and owned by Dakuvi)
Project (per open project, one instance per project) <- "project" layer, rootDir, id, store handle, SessionManager
How you use it:
- Pick a project first, then a session of that project. Project is the
parent of session; there is no such thing as "the session with id X" without a
project. Every lookup, on the wire and in the UI store, is
(projectKey, sessionId). - One socket, pinned on connect. The WebSocket carries one connection that is
pinned to a project when it opens, and every message also carries
projectKey. Per-project state lives in aMapkeyed byprojectKey— never in a shared global. - Managing projects on the wire: the
workdirfield on a session message pins it to the project the server owns for that directory; the server opens (or reuses) thatProjectinstance on first use.session:createtargets that project, so a new session lands in the selected project's store;session:setConfigwithout asessionIdnames the layer to write into (global/defaults/project). - Isolation is the point. A key present in project A shadows the same key in
project B only for A's own sessions; a key only B carries makes A fall through
to
global/defaults. Adding or removing a project never touches another project's records, and removing one leaves the others alive. A session created in one project is invisible in another. Seedocs/04-data-model.md(Records Are Per Project) for the case-by-case consequences. - Where the config goes. Set a key in
global(or the environment) and every project sees it. Set it in<workdir>/.dakuvi/config.jsonand only that project sees it. Set it on a session and only that session sees it.
Using a real model (OpenAI-compatible)
In .dakuvi/config.json (local or global), point providers.openai.baseUrl at any
OpenAI-compatible server (LM Studio, vLLM, Ollama):
"providers": { "openai": { "baseUrl": "http://localhost:1234/v1", "model": "google/gemma-4-12b-qat" } },
"defaultModel": "openai/google/gemma-4-12b-qat"
Model selection (provider/modelname)
A model is addressed as provider/modelname (split on the first slash). Set it:
- At session start - the UI/API
session:createaccepts a model spec. - At runtime - type
/model openai/google/gemma-4-12b-qatin chat, or/modelalone to see the current selection + available providers. Chat slash commands are registry-driven:/helplists every registered command (core + plugins, e.g./refinfofrom the reference plugin), and unknown slash words fall through to the model. - As a default -
defaultModelin.dakuvi/config.json.
Per-turn precedence and the full provider/modelname addressing rules (split
on the first slash, bare-token disambiguation) are documented in
docs/03-05-model-providers.md (Model Addressing). Forks inherit their
source's model. Changing it never touches file state and applies from the next
turn.
Documentation
The full design documentation lives in docs/index.md, which
is a table of contents pointing to one file per topic. Start there:
- Current state - what is actually implemented vs. planned:
docs/state.md - Architecture overview:
docs/02-architecture.md - Agent loop (turns, steps, step budget & continue, model selection):
docs/03-01-agent-loop.md - Session model (forking, undo/redo halves, lifecycle):
docs/03-02-session-model.md - Hook system (the hook points: turn, step, context/model, tool, permission, change-management, lifecycle,
command_before; retroactive rewrite + re-execution preamble):docs/03-03-hook-system.md - Model providers (
provider/modelnameaddressing, precedence):docs/03-05-model-providers.md - Web UI (module map, data flow, rendering model, WebSocket protocol, server):
docs/03-10-web-ui.md - End-to-end tests (unit, backend e2e, headless UI e2e):
docs/08-end-to-end-tests.md - Plugin API reference:
docs/05-plugin-api.md - Internationalization (i18n) (segments, lazy loading, language detection, plugin contract):
docs/09-i18n.md
Each docs/*.md file states its own status (implemented / planned) where it
matters; state.md is the single source of truth for "is this built?". The
rest of the component docs (hooks, plugins, tools, permissions, change
management, MCP, data model, scope, open questions, e2e test design) are linked
from docs/index.md.
Testing
npm test # = check + build + unit tests + backend e2e + UI e2e + i18n
npm run test:unit # unit only
npm run test:e2e # backend e2e only (live WebSocket protocol)
npm run test:ui # UI e2e only (headless browser)
npm run test:i18n # i18n completeness only (locale key parity, no server)
All four are hermetic: the first three run on a dedicated deterministic
test dummy provider (plugins/providers/test-provider.ts) in a scratch
workdir; the i18n test is a stand-alone static check of the locale files and
UI sources. None need a network or external model. Real endpoints are
validated separately (ad-hoc), not by the committed tests.
The backend e2e (test/e2e.test.mjs) drives the live WebSocket + /api
protocol against a spawned dist/main.js, and the unit layer
(test/unit.test.mjs) includes the two-project regression group: two Project
instances over two workdirs in one process, asserting that a project-layer value
only reaches its own project's sessions, that a project's SessionManager
resolves over its own project record only, and that one project's sessions are
invisible in the other. The UI e2e is headless: it drives the actual
compiled dist/ui in a browser against the real backend and asserts on the
visible DOM (session creation, turn streaming + the response rendering,
multi-session content, and switching back). test/ui-e2e.test.mjs is the
runner (the UiE2eTest class, which owns the environment + every helper);
test/ui-e2e/ui-test-base.mjs is the shared UiTest base class; and
test/ui-e2e/NNN-*.mjs is one module per numbered regression group (each
exports a class extending UiTest), auto-discovered and run in numeric order. It launches the system Chromium via Playwright
(executablePath: /usr/bin/chromium-bin); override with
CHROMIUM_BIN=/path/to/chromium. Requires playwright (dev dep - see
docs/08-end-to-end-tests.md for the full design).
Project layout
src/core/ core: Dakuvi (the one main server, process level), Project (the
per-project layer: project config record, store handle,
SessionManager), turn engine (on SessionManager), sessions, hooks,
permissions, tool registry/pipeline, providers, MCP, WS server
src/main.ts bootstrap - wires everything together and starts the server
plugins/ one plugin per concern (TypeScript, compiled to dist/plugins/ at build time),
grouped by base class - the runtime loader discovers them recursively:
plugins/tools/ ToolPlugin: read-tool, edit-tool, write-tool, bash-tool (the standard tools)
plugins/providers/ ProviderPlugin: openai-provider (OpenAI-compatible), echo-provider (reference), test-provider (test dummy)
plugins/storages/ StoreProviderPlugin: sqlite-store (persistence backend)
plugins/hooks/ plain Plugin: reference-plugin (demo hooks/permissions/UI/command), git (file-state over git), context-compaction (context-window management), debug (LLM-response logging to debug.log, off by default)
ui/ Web UI - served by the core. Layered, observer-driven TypeScript:
`app.ts` (entry) + `ui/src/{app,controller,core,io,render,store}/` -
one way: server->WS->controller->store->observers->views;
click->actions->store/send. `types/` are pure .d.ts (protocol +
view models, no runtime bytes). `css/` is modular CSS (main.css
imports the group files incl. variables.css tokens). See
docs/03-10-web-ui.md for module-by-module responsibilities.
test/ test suites: zero-dep unit + backend e2e (live WS proto) + UI e2e
(headless Playwright over the real dist/ - one module per
numbered regression group under test/ui-e2e/, each exporting a
class extending the shared UiTest base; the runner owns the
environment + helpers: test/ui-e2e.test.mjs + ui-test-base.mjs)
docs/ design docs (architecture, agent loop, tools, providers, plugin API, scope, state)
dist/ build output: main.js (single-file backend bundle), ui/ (Web UI assets:
index.html, app.js (single-file IIFE bundle of ui/app.ts + ui/src/*),
css/main.css bundled from ui/css/), plugins/ (one self-contained bundle
per plugin, same group layout)
How it fits together (one turn)
One SessionManager per project instance drives every turn: runTurn(sessionId, message) on a new message, continueTurn(sessionId) after a budget pause, plus
cancel(sessionId) / killStep(stepId). Sessions are records; the store handle and the
project layer below the session records are that project's Project instance's, the
registries and the defaults/global layers are the main server's,
and per-turn state (step counter, the duplicate-call guard, the running-turn and
in-flight-step controllers) hangs off the manager keyed by session/step id - there is
no separate stateless loop object handed a bag of singletons.
user message -> [turn_start hooks] -> build context (+compaction) -> select provider/model
-> provider.chat() streams normalized events (tokens / thinking / tool_calls / usage)
+- for each tool_call:
| permission gate (allow/ask/deny) -> tool_before -> execute
| -> tool_after (can scrub/replace the result) -> append to context
-> final answer -> [turn_end hooks] -> file-state commit point (git plugin)
-> persist + advance conversation pointer -> stream events to the UI
(when the per-turn step budget trips, the turn is paused and can be resumed
with /continue or the inline "continue" button - no new user message)
Every internal step is a hook point; plugins attach behavior by overriding base-class
methods. The permission gate always runs before any tool executes, and tool_after can
rewrite what the model sees - e.g. scrubbing secrets out of file reads before they reach the LLM.