A llm harness framework, built to be extendable, formerly Zenaku
  • TypeScript 67.2%
  • JavaScript 27.6%
  • CSS 3.7%
  • HTML 0.9%
  • Mustache 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-22 06:09:44 +02:00
.dakuvi project rename, Qwen3 had major issues with Zaneku 2026-10-19 19:53:38 +02:00
docs updated documentation 2026-10-22 06:01:07 +02:00
eslint-rules code formatting 2026-10-19 19:54:42 +02:00
plugins fixed autoscroll in the UI 2026-10-22 06:09:44 +02:00
src replace console.log 2026-10-22 04:35:11 +02:00
test fixed autoscroll in the UI 2026-10-22 06:09:44 +02:00
ui fixed autoscroll in the UI 2026-10-22 06:09:44 +02:00
.gitignore project rename, Qwen3 had major issues with Zaneku 2026-10-19 19:53:38 +02:00
esbuild.config.mjs new per-project layer 2026-10-22 02:32:11 +02:00
eslint.config.js new per-project layer 2026-10-22 02:32:11 +02:00
package.json new per-project layer 2026-10-22 02:32:11 +02:00
README.md updated documentation 2026-10-22 06:01:07 +02:00
tsconfig.json combined classes 2026-10-19 19:54:43 +02:00

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-in node:sqlite).
  • Git on your PATH - only if you use the git change-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 defaults layer 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 a Map keyed by projectKey — never in a shared global.
  • Managing projects on the wire: the workdir field on a session message pins it to the project the server owns for that directory; the server opens (or reuses) that Project instance on first use. session:create targets that project, so a new session lands in the selected project's store; session:setConfig without a sessionId names 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. See docs/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.json and 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:create accepts a model spec.
  • At runtime - type /model openai/google/gemma-4-12b-qat in chat, or /model alone to see the current selection + available providers. Chat slash commands are registry-driven: /help lists every registered command (core + plugins, e.g. /refinfo from the reference plugin), and unknown slash words fall through to the model.
  • As a default - defaultModel in .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:

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.