KIWI
Autonomous local AI agent and community of voices living in The Grove with private memory.
The Problem
Autonomous AI agents often suffer from leaky memory isolation, unpredictable side-effects on host systems, and lack of human-in-the-loop governance for high-stakes file operations and external device actions.
My Role
Architected and implemented the cognitive engine ("The Grove"), fail-closed host action safety policies, immutable exact-action plan ledgers, multi-device agent companion protocol, and unified multi-channel gateways.
Highlights
- Community of six voices living in The Grove with local memory running on the user's machine
- Local cognitive core ("The Grove") separating owner memory from credentialed client sessions
- Fail-closed security policy requiring immutable exact plans with rollback for all destructive actions
- DPAPI-secured Windows companion agent with local execution receipts and anti-re-execution guarantees
- Multi-modal interaction surface: Next.js Web UI, Voice HUD (:4321), and Telegram bot gateway with inline approvals
- Deterministic audit ledger tracking every state mutation, file movement, and memory association
Stack
Constraints
- Host safety is non-negotiable: no autonomous file deletion or batch reorganization without human-confirmed exact plans.
- Offline-first engine: core cognitive loops and memory databases must run purely locally without cloud dependencies.
- Multi-tenant memory isolation: administrative sessions and API token holders must never access the owner private mind context.
System Architecture
Key Trade-offs
The decisions worth defending — what I chose, what I turned down, and why.
File management execution model
Chose
Immutable two-phase exact-action plans with hash verification and dry-run preview
Rejected
Direct agent tool execution with broad shell permissions
Agents making speculative shell or file modifications risk catastrophic data loss; two-phase plans guarantee human oversight and rollback capability.
Engine-to-web communication architecture
Chose
Dedicated loopback HTTP/SSE gateway with bearer token authentication
Rejected
Monolithic single-process Next.js application
Decoupling the AI engine process from the Next.js frontend ensures background monitoring, telegram polling, and long-running skills survive web redeployments.
Device pairing security model
Chose
Windows DPAPI machine-bound tokens with polling architecture
Rejected
Inbound SSH or remote socket servers on client machines
Inbound listening ports expose clients to local network scans; outbound HTTPS polling behind DPAPI keeps remote companion devices secure.
What I'd Do Differently
An honest retrospective — the stuff I'd change with more time, more users, or a second pass.
- 1Implement automated regression tests for memory graph decay sweeps earlier in the development lifecycle.
- 2Consolidate SQLite schema migrations between better-sqlite3 and pg-schema under a single unified migration runner.
- 3Add fine-grained telemetry dashboards to observe cognitive latency across multi-turn reasoning chains.
Interactive Autonomous Agent Shell
Test live KIWI autonomous agent supervisor routines (agent status, grove ping, mcp tools, help) in the emulated shell below.
Technical Deep-Dive
Architecture, specifications, and implementation details.
P2 — Distribution and CLI client
Revision 21, 2026-09-09. First real P2 slice, chosen because the operator has no Windows machine available this session (the P1 Windows device pilot's own physical acceptance stays blocked) and package-name ownership (npm install kiwi) is unresolved — see "Confirmed against the npm registry" below. Local code and automated verification; no production rollout, no package published.
#Confirmed defect and fix — Pro subscribers could not use the CLI they pay for
web/src/app/(dashboard)/dashboard/cli/page.tsx is gated requirePlan('pro') and its copy reads "Pro plan — Kiwi in your terminal, next to your code"; the shipped client web/public/kiwi.mjs itself prints "No machine token issued — CLI access requires the Pro plan." on failure; web/src/lib/utils.ts's PLANS.pro.features explicitly lists "CLI access — connect Kiwi to your terminal" as a Pro-tier feature. All three independently agree the bar is Pro. But POST /api/os/login (web/src/app/api/os/login/route.ts) only issued a machine token when plan === 'enterprise' && status === 'active', or when the account had no subscription row at all (a legacy exemption). An active, paying Pro subscriber logged in successfully and received no token — unable to use the feature their plan advertises.
Fixed by using allowedForPlan(plan, status).cli (web/src/lib/plan-guard.ts) — the same rule that already, correctly, gates the /dashboard/cli page (paid = plan is pro or enterprise, status active) — instead of the route's own narrower, Enterprise-only inline check. The !sub legacy exemption is unchanged. This was verified not to widen an unrelated Enterprise-only feature: web/src/app/api/download/iso/route.ts's hasEnterprise() independently re-queries the caller's subscription on every request rather than trusting that a valid agent-key token implies any particular plan, so a Pro-issued token still gets 403 Kiwi OS is an Enterprise feature there. /api/chat and /api/kiwi/status (the two endpoints kiwi.mjs actually calls) resolve identity via currentUserIdFrom with no plan re-check at all — any valid agent-key token already worked for them; the only real gate was ever at token issuance.
New tests: web/tests/os-login-entitlement.test.ts (7 — active Pro receives a token that genuinely authenticates /api/chat; active Enterprise still works; a legacy no-subscription account still works; canceled Pro and active Free receive no token; wrong password is rejected regardless of plan; a Pro-issued token still gets refused by the independently-gated ISO endpoint).
#Client hardening — web/public/kiwi.mjs
The shipped, zero-dependency, curl-downloaded CLI client had four issues, none previously covered by any test:
main()ran unconditionally at module import, making the file untestable without triggering real network calls andprocess.exit. Now guarded:const isMain = argv[1] && import.meta.url === pathToFileURL(argv[1]).href; if (isMain) main()...— importing the module (as the new test file does) runs nothing.- The password prompt used plain
readline, which echoes every typed character to the terminal.askHidden()puts stdin in raw mode and consumes it manually (Enter submits, Backspace/DEL edits, Ctrl+C exits like the rest of the CLI), falling back to the existing visible prompt when stdin isn't a TTY (nothing to hide from a non-interactive caller, and raw mode requires an interactive terminal). KIWI_APIlet an operator override the server origin to anything, including plainhttp://, while the response carries a long-lived Bearer token — a cleartext origin would leak it.resolveApiOrigin()now requires HTTPS unless the host is a loopback address, mirroring the exact rule the Windows companion'sserverOrigin()(src/devices/agent.ts) already enforces for the same reason. Every network call also gained a 15s timeout andredirect: 'error', again matching the companion.- Server-controlled strings (error messages, chat responses, status fields) were printed to the terminal unsanitized.
clean()strips the same control-byte range (\x00-\x08,\x0b-\x1f,\x7f) the companion's ownclean()insrc/devices/agent.tsstrips — removing the ESC byte that turns following bytes into a live escape sequence, not a claim of removing every visible artifact of one.
Also added: --version (was missing; kiwi-device in the companion already has one).
New tests: web/tests/kiwi-cli-client.test.ts (5 — rejects non-HTTPS/non-loopback origins, accepts HTTPS and loopback HTTP, defaults to the production origin, strips control bytes, exports a version string). The interactive TTY prompt and the real network calls are not unit-tested — same boundary as the companion's executeFileEdit (tested) versus its readline-driven shell (not).
#Packaging gate — packages/companion and a stale-artifact fix
packages/companion/dist/ (the compiled @kiwi-local/companion companion) was stale coming into this session — it predated the revision 19 restore mode and revision 20 markAcknowledged reconciliation fix, since nothing in this session's earlier work had rebuilt it (npm run test/typecheck only check src/, never the compiled companion output). Recompiled (npx tsc -p tsconfig.companion.json) and smoke-tested (--help/--version). packages/companion/dist/ is gitignored — a build artifact, not something to commit; keeping it in sync is a build-time concern, which is what this section's new script addresses going forward.
New scripts/verify-companion-package.mjs (npm run verify:companion) is a packaging gate that runs on any OS, since packing/inspecting a tarball is platform-neutral even though the package itself only installs on win32 (package.json's os:["win32"], required by the DPAPI credential store src/devices/credentials.ts needs). It checks, in order: (1) the package builds cleanly from src/devices/*; (2) no preinstall/install/postinstall/prepare/prepublish/prepack script exists — P2's plan explicitly forbids starting an interactive application from an npm install lifecycle hook, and this makes that a checked invariant rather than a one-time claim; (3) npm pack --dry-run --json's real file listing actually contains the declared bin entry and every declared files entry — not just what package.json claims; (4) the built --version output matches package.json's version field, the exact class of drift the stale-dist issue above was; (5) --help mentions every documented mode. On win32 only, it additionally does a real npm pack → npm install -g <tarball> --prefix <isolated temp dir> → --help → npm uninstall → confirms the prefix's bin is empty round trip; on any other OS this step is printed as skipped with the reason (npm's own os field refuses the install there — a correct refusal, not a gap to work around). Run on this Windows machine: all 10 checks passed, including the real install/uninstall round trip (.verify-work-style JSON report printed to stdout, exit code reflects pass/fail).
Also removed the root package.json's "bin": {"kiwi": "./src/cli/kiwi.ts"} — the repository is "private": true and was never installed via that path; the field pointed at a raw .ts file with no shebang, which cannot actually run as a bin script, and the real, only supported way to run that file is npm run cli (node --import tsx src/cli/kiwi.ts, src/cli/kiwi.ts's own docstring). This did not change how the dev CLI is invoked — it removed a declaration that never worked and was not the file's own documented entry point.
#What src/cli/kiwi.ts is, and is not
src/cli/kiwi.ts (npm run cli) is a separate, pre-existing, single-operator terminal chat tool that talks directly to createKiwi() (the local engine object in-process) on this machine — no account login, no remote API call, not multi-tenant. It is not a candidate to become the P2 distributable product without first deciding to make it call the web's authenticated API remotely instead of the in-process engine — that is itself a real design decision, not something this revision made.
#Verification
Engine: 138 tests (137 passed + one optional semantic skip; unchanged — src/ outside src/devices/ untouched). Web/mind: 249 tests in 39 suites (+12 tests, +2 suites: os-login-entitlement.test.ts, kiwi-cli-client.test.ts). Both typechecks passed. npm run verify:companion: 10/10 checks passed on this Windows machine, including the real install/uninstall round trip. No production rollout; os/login's entitlement fix has not been deployed.
#Remaining P2 work
Full coding/diff-approval TUI (deliberately out of scope for this revision — it is dev_task's eventual safe replacement, a separate, security-sensitive design effort); npm package-name/registry ownership (kiwi is already taken by an unrelated party on the public registry, confirmed via npm view kiwi; the @kiwi-local scope is free but claiming it is an npmjs.com account action, not code); browser-login TUI experience beyond the existing kiwi.mjs login flow; public distribution and any real clean-machine (not isolated-prefix) install/update verification; code review; controlled rollout.
Sources: web/src/app/api/os/login/route.ts, web/src/lib/plan-guard.ts, web/src/lib/agent-keys.ts, web/src/app/api/download/iso/route.ts, web/public/kiwi.mjs, packages/companion/, scripts/verify-companion-package.mjs, Windows pilot, checkpoint.
#Revision 38 (2026-09-10): kiwisan published on npm; a live production login bug found and fixed; slice 1 of the full-screen TUI
The npm package-name question, actually resolved. kiwi (bare, unscoped) is permanently taken on the public registry by an unrelated, actively-maintained package (a template engine) — confirmed not obtainable (no abandonment, no trademark dispute npm would act on). At the operator's direction, claimed kiwisan instead and published it live under their own account: packages/kiwi-cli/ wraps the already-hardened kiwi.mjs script as a real global kiwisan bin, not a placeholder. npm install -g kiwisan verified end to end against the real registry. packages/companion stays private/local per an explicit operator decision — nothing from it was published.
A real, live, production-breaking bug found and fixed along the way, via an Opus investigation pass (per this session's standing workflow rule — bug reports always get an Opus read-only diagnosis before a fix, not a self-diagnosed patch): web/src/middleware.ts's PUBLIC_API allowlist never included /api/os/login — every unauthenticated login attempt (the only kind that endpoint ever receives, since its whole purpose is to issue the first token) was intercepted at the middleware layer with a generic {"error":"Unauthorized"} before the route's own email+password/bcrypt logic ever ran. A chicken-and-egg deadlock: you need a token to get a token. The CLI's login has never worked in production, for anyone, until this fix. Not a plan/entitlement issue (the account that hit it was already enterprise/active) and not the route's own logic (its real error strings are "Invalid credentials"/"Email and password are required", never "Unauthorized" — proof the request never reached it). os-login-entitlement.test.ts couldn't catch this: it calls route.POST() directly, bypassing middleware entirely. New web/tests/os-login-middleware.test.ts asserts the unauthenticated POST now passes through, and that an unrelated os/* path is still protected. Fixed by adding the exact string '/api/os/login' (not a /api/os/ prefix) to PUBLIC_API. Deployed to production and verified live via curl ({"error":"Invalid credentials"} for bad credentials, not "Unauthorized").
Two real, live Windows crashes in kiwi.mjs itself, found and fixed the same day, by the operator hitting them in a real terminal: (1) login() read the email via a readline.Interface (created/closed per call) and the password via direct stdin.setRawMode() manipulation immediately after — on Windows, closing a readline interface's stdin handle and then toggling raw mode on the same handle races libuv's own async handle teardown (Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src/win/async.c). Fixed by routing both prompts through one raw-mode implementation (promptLine), never touching readline.createInterface during login. (2) The same crash still reproduced afterward, from a different cause: process.exit() racing any async handle's teardown — a raw-mode toggle, a fetch timeout timer, a socket — not just readline specifically, right after a failed login printed its error and called exit(1) following an awaited fetch(). Fixed by never calling process.exit() anywhere in the file: every exit path now throws CliExit(code), caught once at the very bottom, which sets process.exitCode and lets Node exit naturally once the event loop drains.
Slice 1 of the operator's full-screen-TUI request, scoped by an Opus pass then built: kiwisan/kiwi.mjs now enters a persistent, hand-rolled-ANSI full-screen frame (alternate screen buffer, the same 16×16 pixel-art character the web /os desktop shows, a scrollback transcript, a bordered input line, a status bar) after a successful login or when run bare in a real terminal — piped input, piped output, and one-shot kiwisan "message" are entirely unaffected, exercising the same code paths as before. Chat-only: no file edits, no shell execution — that stays a deliberately separate, later, security-reviewed slice, meant to render through the already-shipped approval executor (src/devices/local-executor.ts, from the P1 device pilot) rather than a new one. Persona pinned to forge (Papa Ko) by default via the already-working @forge <message> mention syntax (chat-router.ts's detectIntent) — no server change needed for that part; /persona <name> switches among all six. See docs/adr/0011-kiwisan-tui-hand-rolled-ansi.md for why this is hand-rolled ANSI and not a terminal-UI library (web/public/kiwi.mjs's curl-distribution channel structurally cannot carry npm dependencies, and libraries in the ink family drive the exact raw-mode stdin surface that just crashed twice). New scripts/verify-cli-mirror.mjs (npm run verify:cli) — the two kiwi.mjs copies had no automated sync check before this; it now fails if they diverge beyond their known channel-name text, or if VERSION drifts from packages/kiwi-cli/package.json (it already caught exactly that drift once while writing this slice).
Verification: npm run verify:imports — 678 files clean. npm run verify:cli — clean. web/tests/kiwi-cli-client.test.ts extended to 15 tests (was 5) covering the new pure functions (renderSprite, wrapToWidth, composeFrame, visibleLen, padRight) — including a regression test for a real infinite-loop bug wrapToWidth had on its first version (a word longer than the pane width, when the current line was already empty). npm run test:mind — 52 suites / 364 tests, all green. npx tsc --noEmit (web) clean.