@kinedb/kdmem (0.1.12)
Installation
@kinedb:registry=https://git.kinedb.com/api/packages/kinedb/npm/npm install @kinedb/kdmem@0.1.12"@kinedb/kdmem": "0.1.12"About this package
kdmem: the shared live memory of a team
One central kinedb server (kdmem.kinedb.com) holds the shared state. A daemon on each machine
publishes the state of its Claude sessions. A web page shows all machines and branches.
Install
kdmem is published on the kinedb package registry, never on npmjs (browse: https://git.kinedb.com/kinedb/-/packages). Reading it needs no login. Run this ONE command on each machine. It makes the device key, installs the mod and starts the daemon:
bun x --registry=https://git.kinedb.com/api/packages/kinedb/npm/ @kinedb/kdmem --user you@yourcompany.example --company yourcompany
Replace you@yourcompany.example with your own e-mail address and yourcompany with the name of your own
company. The command stops with an error if you keep the example text. It takes the latest version, so it
has no version number (B128). bun x is the same program as bunx; two people reported that bunx fails on their
computers, and the cause is NOT DETERMINED. Write the = after --registry: a space fails with "unrecognised dependency format".
kdmem --help shows a second way (the registry line in ~/.npmrc, then bun x @kinedb/kdmem).
The command asks the server whether the company exists. A company that exists adds a NEW person by itself (B14): the
server makes the user, adds it to the company and attaches the key of the device, so nobody approves anything and the
setup ends with state: APPROVED. The device pins the root key of the company. An older install with no pin takes the key from the database once, by itself; a pin that exists is never replaced. The e-mail address is NOT proven:
anyone who knows the company name can join. The operator turns this off with KDMEM_AUTO_JOIN=0. An address that
already exists is never joined this way, because a key must not go onto an existing account. An admin of the company
adds that device on /manage (Devices tab). A new company is made by the command itself when the
server allows it (KDMEM_SELF_SERVICE=1; off on kdmem.kinedb.com): the user becomes the first admin, and the
key of the device becomes the root key of the company.
The password. A person made by a join or a signup has NO usable password: the server makes a random one that nobody keeps, and the
device key is the proof. To sign in on the fleet page or on /manage, the person sets its own password with the device key (no old password
needed; the server turns the switch SET_OWN_PASSWORD on for a join, a signup and an admin reset). The daemon file runs it:
bun "$(ls -t ~/.config/kdmem/code/agents-*.js | head -1)" --password # asks twice, the typing is hidden
bun "$(ls -t ~/.config/kdmem/code/agents-*.js | head -1)" --password --generate # a random password, shown ONCE
An admin of a company (or a global admin) sets a new temporary password for a member on the Users tab of /manage ("Reset password",
two clicks). The password shows once. An admin cannot read or set a password of another company, and only a global admin resets the password
of a global admin. A person cannot change the password of another person with the device key.
A signed-in person changes its own password on /manage (any person, also a user who is no admin): the tab My password asks for the old
password and the new one twice (6 to 128 characters). The service checks the old password again, takes the person from the session and
counts a wrong old password as a failed login. An admin can also TYPE a password for a member: the form "Set a password for" on the Users
tab (the password is not shown again). "Reset password" stays for a random temporary one.
Status: @kinedb/kdmem 0.1.12 is published. The command of 0.1.5 is checked on Linux, macOS and Windows. NOT YET
checked for 0.1.6 to 0.1.12: the full command on macOS and Windows. 0.1.8 added the messages between sessions, the mod from the
database and the self reload of running sessions (B14). 0.1.9 adds the join of a new person with no admin step. 0.1.10 stops the
command when the example address or company of the README is kept. 0.1.11 carries the mod with the commands, hooks and prompts
from the database (a new machine gets them from the database at once anyway). 0.1.12 carries the fixes B117 (a new install no longer
allows every folder), B120, B121, B125 and B126 in the daemon and the mod; the install of 0.1.12
is NOT YET run on a clean machine. The signup, the lists of signers and the company layer
are checked against the live server by the probe q14-signup-e2e.ts.
What starts the daemon
No operating system service starts it. The install command starts it once. After a restart, the
first Claude session that starts runs the mod hook: it reads the heartbeat file of the daemon
(~/.config/kdmem/heartbeat.json), and it reads it again at each trigger (a prompt, a tool call, a turn end,
the 2 s message tick), at most once in 30 seconds (B126). A live daemon gives no command at all. A stale
heartbeat (2 minutes) makes the hook run <daemon file> --ensure once, and that call starts the daemon
detached. One start in a minute at most. A daemon that cannot publish for 10 minutes ends itself (B125), so
its heartbeat goes stale and the next check starts a new one. The daemon replaces its own code from the signed database.
The daemon does not poll for it: a WATCH on the table kd_assets wakes it when a skill or the code
changes (a safety pull every 5 minutes stays; without a working watch it pulls every 30 seconds).
The mod is a thin shell. A mod cannot evaluate script text (probe 2026-10-18: new Function and eval
fail with "Code generation from strings disallowed"), so it cannot load a script file. Its numbers
(the stale limit, the gap between starts, the check interval) come from ~/.config/kdmem/mod-config.json,
which the daemon writes at its start. A new daemon bundle from the database changes them with no
reinstall of the mod. The mod code itself also comes from the database (B14): bun daemon/assets-cli.ts <env> mod publishes the hooks and the
plugin.json as signed rows mod/.... A mod can load only files inside its own plugin folder, so the daemon writes a marketplace
folder of its own (~/.config/kdmem/mod-market) and runs claude plugin marketplace add|update and claude plugin install|update.
A running session reloads by itself: after each pull the daemon writes ~/.config/kdmem/reload.json ({ mod, skills }). The mod reads
the file every 5 seconds. A changed value makes the mod run the commands reload-plugins or reload-skills ($.command.run, the name
has no slash). Only a session that started before this feature needs one restart. The reload waits until the session is idle (no turn runs) and the value was stable for 20 seconds, so several releases are ONE reload (reloadPlan in mod/hooks/reload-core.js). mod is the key of the mod rows. skills is the
key of the skill and command files that the daemon wrote (assets-manifest.json), so a release of the mod or the daemon does not
run reload-skills.
The mod has no file watch. It compares the text of commands.json, hooks.json and prompts.json every second, so a change that the
daemon writes (a package turned on) takes effect within about a second.
What every session used: models, run time and tokens (B96, B97)
The daemon logs the tokens of ALL sessions of its computer, with no skill and no package. It reads the transcripts that Claude Code writes
(~/.claude/projects/*/<session>.jsonl and, for each subagent, <session>/subagents/agent-<id>.jsonl) and stores one row for each session
and each subagent in the table kd_arc_tokens (tokens-scan.ts, arc-tokens-ship.ts). Make the table once with
bun daemon/company-cli.ts <env-file> arc-tokens-table (run it again to add the columns cwd and branch to the table of B96). Each person
reads, writes and removes only the own rows. Read them with bun daemon/tokens-cli.ts [days] [text], for example ... 3 B96 or ... 1 opus.
- The daemon keeps an offset for each file and reads only the new bytes (at most 32 MB for each pass of 5 seconds, the newest file first). It reads only a transcript that changed in the last 30 days (B99; it was 3 days). At its start it needs a few minutes to read a big backlog (measured on one computer: 3,936 rows and 8,373 hour rows, about 7 minutes).
- The table
kd_arc_hourly(B99) holds one row for each session, subagent, hour (UTC) and model: requests, input, cache write, cache read, output, thinking tokens and time, tool calls, tool errors andms, and the company, the folder, the branch and the issue. A request counts in the hour of its LAST line, a tool call and its error in the hour and model of the request that made the call. The graphs of the page/usageread this table. The daemon sends only the hours that changed since the last stored shipment, at most about 200 hours in one pass.company-cli.ts <env-file> arc-tokens-tablemakes it, andarc-hourly-companygives the role of each company the right to READ the rows of its own company (WHERE company = ...). So the people of a company see the usage of each other: this changes the rule "each person reads only the own rows" for this table. It holds no text of a task, no tool name and no description. Each person still writes only the own rows. B112:addCompanymakes the read grant for a new company (when the table exists), so nobody must runarc-hourly-companyagain. - A row is written at most once in 60 seconds for a session. The last change of a session that stops reaches the table within 60 seconds. A row is replaced, never added.
- The issue is set for each REQUEST (B102; B101 did it for each row). A request takes the first of these sources: the latest command
/arc <id>of its own transcript (a command block at any line; typed text only in the first lines); the command of the main row of the session (so a subagent gets it too); the brancharc/b<id>…orarc/<id>-…of its line (arc/b3191-pr2givesB3191, no letters giveB); the folder namearc-b<id>. A folder likearc-14-company-peershas no letters before the number, so it gives no issue. A request onmainor on a detachedHEADwithout a command has an empty issue. - A session that works on several issues is split. An hour and model with two issues has one row for each issue in
kd_arc_hourly: the key isowner|machine|session|agent|hour|model|issue. The daemon deletes ALL the rows of an hour and model by the columns, then inserts the parts, so an old row without an issue in its key goes away and nothing is counted twice. The issue of the row inkd_arc_tokensis the issue with most requests. A row that was stored without an issue is stored again, with its hours, when the issue is found later. - The guess of the issue (B103). Both tables have the column
issue_guess. The daemon counts the ids (Band 3 to 6 digits, in any case) in the text that a person typed and that the model wrote. It does not count a tool result or the thinking of the model. When one id has at least 10 mentions and at least half of all mentions of an id, it is the guess of the session. It is stored apart from the exact issue and never replaces it. The same guess is in all rows of one session file (a subagent has its own). When the guess appears or changes, the daemon sends all hours of the file again. A guess can be wrong: the page marks it with~. Runcompany-cli.ts <root.env> arc-tokens-tableBEFORE the new daemon runs, because the INSERT names the new column. - An issue id is unique only inside one project: the id
B12of a demo project and the idB12of kinedb look the same in the tables. /compactdoes not reset anything: the session keeps ONE transcript file, and every request keeps its own usage line.peak contextis the biggest value ever, not the value now./clearstarts a new session id, so it is a new row.- The request that writes the summary of a
/compactis NOT in the transcript (B98; measured: no assistant line and no request line in the compact window). So the totals of the requests leave it out. The linecompact_boundaryholds the only numbers, and the daemon keeps them apart, because they are an estimate and not usage:compacts,compacts_auto,compact_pre_tokens(the context that the summary requests read, summed),compact_post_tokensandcompact_seconds. Measured on one session with 34 compacts: about 10.9M input tokens, 1% of its cache reads. The tokens of the summary text itself are not known.tokens-clishowscompactsandcompact read. - The column
by_modelholds the numbers of each model as JSON (B98): requests, input, cache write, cache read, output, thinking tokens, thinking time, tool calls, tool errors andms. A tool call and its error belong to the model that made the call.msis the time of the requests, from the line before a request to its last line: an estimate, because Claude Code does not write the time of a request. A row with more than 8 models keeps the 8 with the most requests. The numbers of the models add up to the numbers of the row. A comparison of models shows speed, size and tool errors. It does not show the quality of the work: the tasks of the models differ. - No text of a transcript leaves the computer. A row holds numbers, the names of the models and of the tools, the folder, the branch and the task
text of a subagent.
KDMEM_TOKENS=offin the environment of the daemon turns the log off on that computer.
For each session and each subagent the row holds: the model (and the count of requests for each model), the start, the end, the wall time (end minus start), the active time (the gaps of more than 5 minutes do not count), the requests, the messages of the user, the tool calls (for each tool name) and the tool errors, the input tokens, the cache write tokens (and the 5 minute and 1 hour parts), the cache read tokens, the output tokens, the thinking tokens, the thinking time, the peak context (the biggest input of one request) and the web searches and fetches.
- One API request writes several lines in the transcript. Only the last line has the final
output_tokensandthinking_tokens, so the daemon keeps the last line of eachrequestId. outputholds the thinking tokens. A session that works on several issues counts all of them together.- There is no price. A price needs a table of the prices of each model, and that table changes.
The page /usage (B100)
The page https://kdmem.kinedb.com/usage (a link "Usage" next to "Manage" on the fleet page) shows the table kd_arc_hourly as graphs and
tables. It uses the sign-in of the fleet page (the token in sessionStorage) and only reads. The files are site/usage.html (the page) and
site/usage-core.js (the logic, with a test in daemon/usage-core.test.ts); deploy-site.sh copies both and the Caddyfile serves them.
- Personal / Company. Personal shows the own rows. Company shows the rows of the company that the person may read (the grant of
arc-hourly-company, see above), so it shows the usage of each person of the company. The list of companies holds the companies that the rows name and the companies that the person manages (B111: the fleet page saves them at sign-in insessionStoragekdmem.companies), so a global admin gets the list also for a company with no row yet. - Time range. The presets 24h, 7d, 30d, 90d and All, in UTC. Drag on the graph to select a range: every graph and table follows it.
Zoom outdoubles the range,Reset zoom(or a double-click on the graph) goes back to the preset. A bar is 1, 3, 6 or 12 hours, or 1 or 7 days (at most 120 bars). - Group by project (the last part of the folder), computer, user, model, issue, branch, main or subagent, or subagent type. Number: output, cache read, cache write, input and thinking tokens, requests, tool calls, tool errors or model time.
- Pie charts of the sessions and of the output tokens (B104, B105, B107). Each grouping has two donut charts: the number of sessions and
the output tokens (the tokens count each hour on its own). A slice shorter than the 2 px gap keeps a small visible length (
pieArcs): a negative length made the browser draw one full ring. The text below describes the sessions chart. When Group by isissue, it shows the sessions with an issue, with only a guess (B103) and with neither. A session has an issue when one of its rows in the range names an issue. For the other groups (for example model or user) a session counts once, in the group with most of its requests, so the slices add up to the total. The colors follow the group, and the groups after the seventh fold into "Other". A table gives the sessions, the share, the requests and the output tokens of each slice (the requests and tokens are those of all groups of the session). - The colors follow the group, not its rank: the order comes from the preset range, so a zoom never repaints a group. The first 7 groups have
a color, the others are "Other". The colors pass
validate_palette.jsin light and dark mode. The hover shows every group of a bar, andTable viewshows the same numbers as a table. - Tables. Totals by group (sums, share of the output), the comparison of the models (output for each request, thinking share, cache read for each
request, tool error rate, seconds for each request and output tokens for each second) and the largest sessions. A column sorts on a click;
a table shows 25 rows until
Show all. The comparison shows size, speed and tool errors, not the quality of the work: the tasks differ.
The viewer role: the global admin reads every table (B113)
The role kd_viewall is the global admin role. A person who holds it reads EVERY table of every company (read only: it has a SELECT grant and
no other right). company-cli.ts <env-file> grant-viewall <email> gives the role to a person and syncs the grants. The sync
(CompanyAdmin.syncViewAll) lists the tables of the server (SHOW TABLES) and grants SELECT on each one, so a table that is made later is
covered too: addCompany runs the sync, and a company-cli.ts command that makes a table (CREATE TABLE) runs it at its end.
viewall-sync runs it by hand. The role writes only through its own company role.
Trace (B14)
The table kd_trace holds a trace of what happens on each machine, so that one query shows a chain across machines. Make the table once
with bun daemon/company-cli.ts <env-file> trace-table. Each person reads, writes and removes only the own rows. The daemon removes rows
older than 7 days.
The fleet page (/) has a Trace view. It reads the rows of the last hour. A feed of the 30 newest events comes first. Below it, one
small table has a row for each computer: how many asks of the engine came, how many a hook allowed (auto-approve) and how many reached
you, the sounds and the time of the hook programs. A watch on kd_trace updates the view while it is open.
- The daemon writes its own events:
choice-changed(with the time from the click to the daemon),pullandhooks-file. - The mod and the hook scripts have no database login. Each drops a small file into
~/.config/kdmem/trace/; the daemon ships the file after its pass and removes it. The mod writeshook-run(the duration of a hook program),files-changedandcheck-ask(each ask of the engine, the final verdict and the number of loaded hooks);play.tsof the packagesoundswritesplay-start,play-spawnedandplay-skipped. On Windows and WSL the PowerShell script of the player also writesscript-begin,player-created,sound-started(the sound really plays) andsound-ended: the time betweenplay-spawnedandsound-startedis the start time of the player. - Read the rows:
bun daemon/trace-cli.ts [minutes] [text]. Each row has the machine, the source, the event, a time in ms and a text. The time of a row is the clock of its machine.
Messages between Claude sessions (B14)
The mod gives each Claude session two tools: peers (the other sessions) and send (a message to a session by name or session id).
It gives a third tool, context: it tells Claude the tokens used, the window size, the percent and the tokens left. Claude cannot see this itself.
A message reaches an idle session: the mod wakes it with a new turn that names the sender and the reply address ([kdmem message from NAME (session ID).]). The frame tells the model nothing about how to treat the text (B95, owner 2026-10-20: the old words "not the user" and "a request from a teammate" made sessions ignore the orders of the owner's own sessions).
- One computer. The mod writes
~/.config/kdmem/msg/outbox/<id>.json. The daemon routes it intomsg/inbox/<session>/. The mod reads the inbox every 2 seconds and writes an.ackfile. - Two computers of one user. The table
kd_messagesis on by default. The daemon sends a message for another computer as a row of the table and delivers the rows for this computer. A message that cannot be delivered comes back as a message fromkdmemwith the reason."messageTable": ""in~/.config/kdmem/device.jsonturns it off; another name picks another table. A server with no table logs the error once. One company. The sessions of a company see each other and message each other.peerslists the sessions of every person of the company (the tablefleet_agents, bycompany), and the file claims (fleet_files) of every person count too, so two people do not change the same file. A message to a session of ANOTHER person carries two more columns,companyandto_owner. The company role reads and marks the rows of its own company; the daemon of the target takes only a row with its own user into_ownerand the same company, and checks both again. A computer is named by its owner AND its name: two people can have a computer callednixos. Every message of a device with a company carriescompany, so the Messages view of the page shows all messages of the company to its members;message-companyalso gives the old rows the company of their owner (run it again after this release). A message never crosses to another company. A device with no company works as before: only the sessions of the own user. Runbun company-cli.ts <env-file> message-companyONCE as root, BEFORE the release: it adds the columns and the company grants (run it again for a new company). A message row is NOT signed. - The table. Make it once, as root (
daemon/messages-remote.tsnames the columns):kd_messageswithmkey(key),owner,from_machine,from_session,from_name,to_machine,to_session,body,at,delivery,note, andGRANT INSERT, UPDATE, DELETE, SELECT ... TO MATCH owner. The server refusestextandstatusas column names. - The page. The fleet page has a Messages view: all messages, grouped by pair (the default), session, computer, project or user. A pair is ONE card for two sessions, with both directions in time order. The card with the newest message is on top. Each session row shows how many it sent and received, with a button to its messages.
The fleet page and a text selection (B94)
A redraw of the page ends a text selection: the page rebuilds the board when a field of a session changes, and the clock cells change their text
each second. So the redraws that nobody asked for (the timer, the data refresh, the refresh of the session actions) wait while the person has
text selected inside the page. They go through renderLive in kdmem/site/index.html. When the selection ends, the page draws the newest data.
A redraw that a click asks for (render) is never held. While text is selected the page shows the data of that moment: the clocks stand still.
The chat of a session on the fleet page (B14)
The Sessions view has a Chat button on each session of your own user. It opens the chat of that session: the text of the turns, a small chip with the NAME of each tool call (never a tool input or a tool result), and a box to write to the session.
- How it works. The page keeps a row in
kd_chat_openalive (a heartbeat of 20 s; a row lasts 60 s). The daemon of the machine reads the rows and writeschat-open.json. The mod of that session writes one file for each chat line into<config>/chat/ONLY while its session is on that list; the daemon ships the files intokd_chatand removes them. The page watcheskd_chat. The text of a session reaches the database only while a person has its chat open, and the daemon removes lines older than 3 days. When a chat opens, the mod also writes the last 40 lines of the transcript (between two marks,earlier messagesandlive; they have no real time). Speed. The mod has no file watch, so it readschat-open.jsonevery 500 ms. The daemon watches<config>/chat/and wakes at once (after 250 ms of quiet, 1 s at the latest) to ship the files, so a chat that opens fills in about 1 to 2 s. Before B14 part 38 it waited for the 2 s read and the 5 s pass. - Writing to the session. The page inserts a row into
kd_messageswithfrom_machine = 'web'andfrom_session = 'web'. The daemon of the target machine delivers it as for any message and setsweb: truein the inbox file, and the mod submits the text as the user. Only such a row is the person. A message of a session that only saysfrom: "web"is refused (outbox-bad) or shows as a peer message. A text of the page that starts with a slash (/compact,/model sonnet) runs as a slash command ($.command.run, queued until the session is idle), because a prompt that a plugin submits is only text for the model. The chat showskdmem: /compact finishedor the reason that it did not run. A message of another session that starts with a slash stays a peer message and runs nothing. - Send now. A normal text of the page waits until the turn of the session is done (
$.prompt.submitruns "once the session is idle"; measured: 43.5 s behind three 12 s tool calls). The check box Send now (off after each send) writespriority = 'now'into the row. The daemon setsurgent: truein the inbox file, for a row of the page only. A busy session (the mod followsturn.startandturn.completeof the main loop) keeps the text and adds it as thecontextof the next tool result of the main loop: the model reads it after the running tool, before the end of the turn (measured: 8 to 14 s earlier). A text that no tool result carried goes out as a normal prompt when the turn ends; an idle session gets it at once. The chat showskdmem: your urgent message reached the model at its next tool call. It cannot interrupt a tool that runs: the model reads the text after that tool. The column is added once, as root:ALTER TABLE kd_messages ADD COLUMN priority TEXT(a table without it still works: the page adds the column only for a Send now text, and the daemon reads all columns).$.session.sendto the own session does not work (the engine says "no live session");$.session.appendalso reached the model in the probe. A session that can run commands can write any file of your user, so this is not a wall against a session that is already out of control. - The tables. Make them once, as root:
bun daemon/company-cli.ts <env-file> chat-table(kd_chatandkd_chat_open; the grantsTO MATCH ownerlimit each person to the own rows). The column isbody, nottext(the server refusestextandstatus). - The trace.
chat-open,chat-close,chat-ship(daemon),chat-open,chat-close,web-message(mod) andchat-open,chat-close,chat-send(sourcepage) go intokd_trace: read them withbun daemon/trace-cli.ts 10 chat. - An old mod shows no text (the page says so after 15 s): run
/reload-pluginsin the session. - The time of a line (B92). A line that the mod writes while the chat is open has its own time. The history that a session sends when the chat
opens has NO time in the session (
$.session.messages()gives the role and the text only). The mod writes the history as ONE file, the lines with the flagh, and the last 100 lines of text (the tool chips do not count). The daemon reads the end of the transcript file of the session, matches each history line from the newest to the oldest (same role, same text), and writes the time into the columntsofkd_chat. A line that it cannot match has no time. A line from another day shows its date. Root adds the column once, before the new daemon runs:bun daemon/company-cli.ts <env-file> chat-time-column. A table with no column still works: the daemon writes the line with no time, and the page reads it without the column. - Share (B92). A Share button sits next to Chat on each session of the own user, and in the chat view. It makes a link to the last 100
lines of the text of the session. Tool calls are left out. A session ships its text only while a page has its chat open, so the button opens the
chat: the mod then sends the history by itself, and the link is made when the history has come (it waits 30 s at most). The page encrypts the
lines in the browser (AES-GCM) and writes the cipher into
kd_share; the key is in the part of the link after#, which the browser never sends to the server. Only the owner writes and removes a row (TO MATCH owner). Every signed-in user may read a row (TO AUTHENTICATED; the rolekd_memberis not on every account), but needs its id, and sees only a cipher without the key. A signed-in person who opens/?share=<id>#<key>sees the lines, read only. The row ends after 7 days; the daemon removes it. A person who has the link can pass it on: the link is the right to read. Make the table once, as root:bun daemon/company-cli.ts <env-file> share-table.
Start a new session from the fleet page (B14)
The Sessions view has a New session button. The form asks for a computer, a folder and a prompt. The computer and the folder are
toggle buttons: one of each is pressed at a time. The form reads the computers again when a daemon writes its folders or a heartbeat changes. The daemon of that computer
starts claude --bg in that folder with your prompt as the first message.
- The folders are the folders of the Scope (B92). No command is needed. In mode
onlythe daemon offers the folders of the Scope tab. In modeallorexceptit offers the project folders that the sessions used (in scope; read at most each 5 minutes). A request starts a session ONLY when its folder is in that list (an exact folder; no parent, no child, no..). A folder that you add or remove in the Scope tab counts at once.device.jsonmay add more folders (bun daemon/spawn-cli.ts add /work/a) or turn the start off for this computer ("spawn": { "on": false }, orbun daemon/spawn-cli.ts off;onturns it back;showandremove <folder>also exist). The daemon readsdevice.jsonand the Scope again on each pass. - Windows: the spelling of the folder. The Scope keeps a Windows folder in lowercase (
c:/users/skade/src/kinedb). Claude keeps its trust of a folder under the exact spelling, so a session that starts in the lowercase folder asks "Is this a project you trust?". A background session cannot answer, and the mod did not load there (no tools, no trace row). The daemon now starts the session in the spelling of the disk (C:\Users\skade\src\kinedb) and compares Windows folders without case and with any slash. A path of another system stays case sensitive. - The session. It runs in the normal permission mode (
--permission-mode default), so it asks before it runs a tool. The auto-approve hook is not part of the start. The prompt is ONE argument after--: no shell reads it. The daemon gives the child noCLAUDE_*variable of the session that started the daemon (it keepsCLAUDE_CONFIG_DIR). - Limits. At most 5 starts in 10 minutes for each computer. A request that waits more than 10 minutes expires. A prompt has at most 8000 characters.
- The flow. The page inserts a row into
kd_spawn(statenew). The daemon wakes at once (a watch), setsstarting(the claim; a second pass finds no new row), starts the session and writesstartedwith its id, orrefused,failedorexpiredwith the reason. The daemon also writes the allowed folders intokd_spawn_host; the form lists only the computers that sent a heartbeat in the last 2 minutes. - The tables. Make them once, as root:
bun daemon/company-cli.ts <env-file> spawn-table(kd_spawn,kd_spawn_host; the grantsTO MATCH ownerlimit each person to the own rows). - The trace.
spawn-request(sourcepage) andspawn-start,spawn-refused,spawn-failed(daemon):bun daemon/trace-cli.ts 10 spawn. - No process in the mod. Only the daemon starts the program (one
spawn(indaemon/spawn.ts, pinned by a test).
Start a session from a session (B92)
A Claude session starts a NEW session in a folder, on its own computer or on another computer of its owner. This is the New session button of the fleet page, as two tools of the mod:
spawn_targetslists the computers of the owner and the folders that each computer allows (the Scope folders, see above), and marks this computer and an offline computer.spawnwithfolder,promptand an optionalmachine(the default is this computer). It returns at once withqueued. The result comes back as a message fromkdmem: the id of the new session, or the reason. The new session then shows inpeers.
How it works. The mod writes files only, in <config dir>/spawn: out/<id>.json is a request, targets.json is the list that the daemon
writes. The daemon (daemon/spawn-api.ts) checks the request (a computer that is online, a folder in its list, a prompt of 1 to 8000
characters), inserts the same row as the page into kd_spawn (the owner is the user of the daemon, never a value of the file) and tells the
session the result. The daemon of the chosen computer starts the session with its own checks (daemon/spawn.ts): an exact folder of its
list, the normal permission mode, at most 5 starts in 10 minutes. At most 20 requests of one computer wait for a result; a request that gets
no result in 15 minutes is answered with that fact. A computer with an old daemon offers no folder, so a request for it is refused with the
list of the allowed folders. The trace shows spawn-api (daemon) for each stored request. A change of the mod works in sessions that
start after it, or after /reload-plugins.
Stop, start and delete a session (B92)
The fleet page and the mod can stop a session, start a stopped session again, and delete a session that does not run (owner 2026-10-20).
- The fold (B110). In each group of the fleet page the live sessions show first. The sessions with no process are in ONE folded line below
them (
▸ 12 sessions not running). The fold is closed at first. A click opens it, and the page keeps the choice for each group in the browser. - The list shows all sessions. The collector also lists the sessions with no live process: it reads
~/.claude/jobs/<id>/state.json(files only, no program start). They have the statusofflineand the state of the job (stopped,done). The newest 50 show. The page chip shows one word (stopped,done; B108), and thepeerstool saysnot running (stopped). The model, the id line and the chip stay on one line (B108). Only the id, the name, the folder and the state leave the computer, never the text of the job. A message to a session that does not run waits until it runs again. - The page. On a session of the own user: Stop on a running session; Start and Delete on a session that does not run. Delete
asks twice. Another person's session has no button. The page writes a row into
kd_session_act; the result shows below the buttons. The buttons of one session (the message count, Chat, Share, Stop, Start, Delete) are always in ONE row (B109,sessionCell). - The mod tool
sessionwithaction(stop,startorremove) andsession(an id frompeers). It returns at once; the result comes back as a message fromkdmem. It works on any session of the same owner that the fleet rows list, also on another computer that is online. It refuses a session that is not listed, an offline computer, and the session that calls it. - What runs. The daemon of the computer of the session (
daemon/session-act.ts) runsclaude stop <id>,claude respawn <id>orclaude rm <id>: one program start in that file, no shell, the id is 8 hex characters. A running session cannot be deleted: stop it first.removedeletes the session, its history and its worktree. It cannot be undone. The daemon never passes a flag that discards unpushed work:clauderefuses, and its text comes back to the caller. - The table. Make it once, as root:
bun daemon/company-cli.ts <env-file> session-act-table(kd_session_act; the grantsTO MATCH ownerlimit each person to the own rows). A row older than 3 days is removed. The trace showssession-apiandsession-act.
Compact a session by itself: the tool bettercompact (B92)
Claude calls the mod tool bettercompact (no arguments) when its context is nearly full, or when the owner asks (owner 2026-10-20).
The mod does the steps that a model cannot do for itself:
- The RESULT of the tool is the compact prompt (
compactInstructioninhooks/compact-core.js; the words come from the old prompt hook of the package arc, no word changed; owner 2026-10-20), with the path<temp folder>/claude-compact-<session8>.md. The mod sends no prompt for it. Claude writes ONE dump file and tells the owner the path. The hook of the package arc no longer adds this text for a typed prompt with the words "prepare for compact": it fired also when the owner only talked about the words, and never on a tool result. TheSessionStarthook of the package still reads the dump file after the compact; its path must stay the same asdumpPath. The mod accepts any path that fits step 2. - The mod watches the finished
Write,EditandMultiEditcalls. A path with the wordcompactAND a folder namedtmportemp(any case;/tmp/...on Linux,...\Temp\...on Windows) is the dump file. The temp folder of macOS is/var/folders/xx/yyyy/T/, with no folder namedtmportemp: a path with the wordcompactinside the temp folder that the tool used (TMPDIR) also counts. A sibling folder does not. The mod never looks for a sentence or an exact name, because Claude changes both (hooks/compact-core.js). A write before the tool call and a failed write do not count. - When the turn ends, the mod runs
$.session.compact(), the same call as/compact. The first try is made AT the end of the turn, with no wait. The call rejects while a turn runs, and a busy session (a fleet leader) starts the next turn 50 ms after the last one ended. A wait before the first try (the old design: 0.5 seconds) then never found a gap (live failure of a leader, 2026-10-20). If a try fails, the mod tries again 20, 100, 250, 500, 1000 and 2000 ms after the turn end while no new turn starts. When a new turn starts, the next turn end tries again. After 40 turn ends, or after 2 seconds of quiet with every try rejected, the mod gives up: the approval switch goes off and a message tells the person to type/compact. - After the compaction, the mod submits
compact completedas the words of the owner (no frame). A compaction that a hook vetoes sends nothing. The text is a normal prompt: it waits if a turn runs.
The approval switch. Not everyone has the package auto-approve, and the write of the dump file and the delete are outside the project
folder, so the session would ask. The mod has its own tool.check hook with a switch, off by default. The tool turns it on: every tool call is
allowed until the compaction ends. Then the switch goes off, and exactly ONE tool call is allowed after compact completed (Claude deletes the dump
file then). The allowance belongs to the id of that call, so a second check of the same call passes and the next call asks again. If Claude reads
the file before it deletes it, the Read takes that one call and the delete asks. If Claude never writes the dump file, the switch stays on.
Live test 2026-10-20, in tmux, with a separate test mod before it came into kdmem, in default permission mode and with only call bettercompact
sent: the tool result was the prompt, Claude wrote the dump file, the mod compacted, Claude received compact completed, read the file and
deleted it, and the session asked no permission question. A submit from inside the tool call started NO turn (checked live). Tests:
kdmem/mod/tests/compact.test.ts, kdmem/daemon/mod-compact.test.ts.
Files that sessions edit (B14)
The mod records each file that a session edits (the tools Edit, Write, MultiEdit and NotebookEdit) as a claim. A claim has NO time
limit: it counts while its session runs. A session that ends removes its claims. A computer that sends no heartbeat for 2 minutes (the lid
is closed, no network) gives no claim: its state is unknown, so the edit is allowed, and we accept that risk. A pull request that stays
open after its session ended is not visible to kdmem; git shows it at the merge. The daemon sends the claims to the table fleet_files and
gives the claims of all computers of the user back to the mod. It writes files-all.json at least once a minute; the mod ignores a file that
is older than 3 minutes (a dead daemon), so a dead daemon never blocks an edit.
- A read or a search. When Claude reads a file, or runs a
Bash,GreporGlobcall that names a file that another live session edited, the mod adds a note after the result. The note names the session and the computer, and tells Claude to use thesendtool. A function is not tracked: only a file. - An edit. The first edit of such a file is refused. The refusal gives the same note. The same edit again passes: Claude has then agreed with the other session, or it has decided to go on. The mod never blocks twice.
- The same file. Two claims are the same file when the path is equal, or when the repository and the path under its root are equal (another computer, or a worktree).
- The files.
~/.config/kdmem/files/<session>.json(the mod writes it) andfiles-all.json(the daemon writes it). - The table.
bun daemon/setup-fleet.tsmakesfleet_files(the columns are in that file) and the grants, as it does for the other two. - The page. The fleet page has a Files view, grouped by session, computer, project or user. A file that two sessions edit has a
shared with Nchip. A user who sees more than one company gets a list in the top bar to pick the company to watch. - Companies.
fleet_filesis a fleet table: it has the columnscompanyanddept, and a company reads and writes only its own rows.
Where kdmem works: the scope of the projects (B14)
Each person decides in which projects kdmem works, on each computer. The default is all projects. The choice is the file
~/.config/kdmem/scope.json. Change it with the daemon file (bun "$(ls -t ~/.config/kdmem/code/agents-*.js | head -1)" --scope …):
--scope only <folder…>: kdmem works ONLY in these folders and the folders below them.--scope except <folder…>: kdmem works everywhere EXCEPT in these folders and the folders below them.--scope add <folder…>,--scope remove <folder…>,--scope all,--scope show. A.is the folder where you type the command.
With the mode only, a session that starts in a folder that is not in the list asks once (the question dialog of Claude Code):
"The question: kdmem is off in , because the folder is not in your scope. Allow kdmem to work here?" (it starts with "The question:" because the gate of the template denies any other question) Yes adds the folder, and the root of its
project, to the list (--scope allow <folder>), and kdmem works in that session. No keeps the folder in scope-declined.json
(--scope decline <folder>): kdmem does not ask again, and --scope allow turns it on later. Not now, a dismissed dialog and a run
with no person (claude -p) write nothing, so the question comes again. The mode except never asks: the person left the folder out on purpose.
A new install starts closed (B117). At its first start the daemon writes {"mode":"only","folders":[]} when the config folder has no
scope.json and shows no earlier run (no heartbeat.json and no assets-version.json). Claude Code was used in many folders before kdmem, and none of
them was asked about, so every folder asks. Only an OLDER install (one of those two files exists, but no scope.json) gets the folders that it has seen,
so that no session stops working. The daemon log says which case it was.
The migration never writes the home folder, the root of a drive, / or a folder of ~/.claude: a session that started there is not a project, and
the home folder would put every folder below it in scope. The first release wrote the home folder on some computers, so the daemon removes it
once (repairScope, marker scope-repaired.json) and adds the project folders that it has seen. A person may add a base folder again on purpose.
The trace row scope-check (B92). At the start of each session the mod writes one trace row, also when kdmem is off in that session. The
text holds the session, the state that the mod believes (in scope, OUT of scope, the mod asks, OUT of scope, no question), the folder
exactly as the engine gave it (in quotes, so an empty folder or a \\?\ prefix shows), its normal form, the mode and the number of folders.
Search the Trace tab for scope-check, or run bun daemon/trace-cli.ts 10 scope-check. The row is written before the question of the mod,
because a background session has nobody to answer.
The Scope tab of the fleet page shows each computer of the person: the mode, the folders (Remove), the folders that the person
declined (Allow) and a field to add a folder (mode only). It also shows the patterns of the company, read only. The tab reads the table
kd_scope (one row for each person, computer and folder; the grants TO MATCH owner limit a person to the own rows, so no signature is
needed). The daemon of the computer publishes the rows at each pull. A click on the page writes a wish row (add or remove); the daemon
applies it to scope.json and scope-declined.json and replaces the row (scope-sync.ts). Until then the row shows "waiting for the
computer". The admin makes the table once: bun daemon/company-cli.ts <env> scope-table.
A session out of scope does nothing: no tools (send, peers, context), no chat, no file claims, no hooks of the database, no usage file, and the daemon does not list it (no name, folder or context leaves the computer). The mod decides ONCE when the session starts, by the folder of the session. Start the session again after a change. The skills, commands and prompts of the packages are files for the whole computer: scope does not hide them.
The company admin names patterns of projects that are ALWAYS included: bun daemon/company-cli.ts <env> scope <company> kinedb billing
(unset removes them). A folder with a pattern as a part of its path (no case) is in scope for every member, and a member cannot leave it out.
The row is signed with the company key (scope.json in the layer); the daemon writes it to ~/.config/kdmem/scope-company.json. --scope show tells it.
Commands, hooks and prompts from the database (B14)
Three kinds of row go to the mod: commands/<name>.md (a slash command), hooks/<name>.json (a hook) and prompts/<name>.md (text for
Claude, like a CLAUDE.md file). The daemon writes NO file for them: not into a repository and not into ~/.claude. It writes
~/.config/kdmem/commands.json, hooks.json and prompts.json. The mod reads them and checks them again every 15 seconds. A change
so works in a session that is already open, with no restart.
Global and project layers. A row projects/<repo>/commands/<name>.md (also hooks/ and prompts/) belongs to ONE project. <repo> is
the repository name that the fleet page shows. The mod learns the project of its session from files-all.json. In a session of that project the
project entry REPLACES the global entry of the same name. In every other project the global entry works. A hook of the project
{"off": true} switches the global hook of that name off in that project. Until the project is known, only the global entries apply.
Hooks are data. A mod cannot load code from the database (on needs a function literal, and eval is blocked), so a hook row holds a rule:
| Field | Meaning |
|---|---|
event |
tool.call, tool.check, prompt.submit or turn.complete. |
tool |
A tool name or a list (tool.call and tool.check). No tool means every tool. |
except |
A list of tool names that the hook does not cover (tool.call and tool.check). |
ask |
allow or deny (tool.check only): the answer when the engine says ask. A deny of the engine never changes. A tool that needs the person (AskUserQuestion, a plan) keeps its dialog. |
match |
A regular expression for the subject: the command (Bash), the path (Edit, Write, Read), the pattern (Grep, Glob), the prompt text, or the answer. |
deny |
tool.call refuses the call with this text. prompt.submit drops the prompt. |
note |
tool.call adds the text after the result. prompt.submit adds it as context. |
run |
An argv list, never a shell line. {subject} in an element becomes the subject, {kdmem} the folder ~/.config/kdmem. Exit code 2 denies with the message. Other output is a note. In tool.check it runs when the verdict is ask (the person is needed); nothing waits for it and its output is ignored. |
prompt |
turn.complete starts one follow-up turn with this text. That turn runs no hook, so there is no loop. |
protocol |
"claude" (needs run): the program speaks the hook protocol of Claude Code, so a gate.ts of a project runs as it is. It gets the event as JSON on stdin (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart, with session_id, cwd and the fields of Claude Code) and runs in the folder of the session. It answers with JSON (permissionDecision deny or ask = refuse with the reason; decision: "block"; additionalContext) or with exit code 2. A Stop block starts one follow-up turn with the reason. Another exit code or a crash never blocks the work. |
after |
true (tool.call with protocol: "claude"): the program runs AFTER the call, as PostToolUse, and also gets tool_response. Its context goes after the result. |
event: "session.start" exists for protocol: "claude" only (SessionStart, source is always startup, because the mod cannot tell a start from a resume or
a compact). The program runs once at the start. Its context goes with the first prompt of the session.
A hook in a sub agent works too: the sub agent has the session id of its parent and an extra agentId (probe 2026-10-18). The hooks of
tool.call run for it. A turn.complete hook ignores the turn of a sub agent. The hooks of the mod run BEFORE the hooks of
settings.json. They cannot remove a hook that a repository defines in its own .claude/settings.json.
Prompts. The first prompt of a session carries the visible prompts as context. A prompt that changes (or appears, or goes away)
WHILE the session runs reaches the session as a message from the mod, once, within about 15 seconds. The text is not repeated after a
/compact: the session keeps the message in its summary only if the summary keeps it. A sub agent does not get the prompts (NOT DETERMINED).
Commands. A row commands/<name>.md becomes a slash command /<name>.
- The text. The body is the prompt.
$ARGUMENTStakes the text after the command; a body with no$ARGUMENTSgets it at the end. A frontmatter block may givedescription:andargument-hint:. Without a description the first line of the body is used. - A run. The mod queues the prompt and submits it as if the user typed it. A timer of 0.5 second submits it, because a direct call in the handler started no turn (probe 2026-10-18).
- A change or a removal. A changed body counts at once (within 15 seconds). Claude Code cannot unregister a command: a removed command stays in the list and answers that it was removed, until the session ends. A changed description shows after a restart of the session.
- The name. Letters, digits,
_and-, up to 64 characters. A name of a built-in command is refused by Claude Code and skipped. - Old files. A command file that an older daemon made is removed once (a file that the user edited stays).
- Skills are not changed. A skill is a prompt that Claude follows with its own files, so it stays a
SKILL.mdfile.
Packages (B14)
A package is a named set of signed rows packages/<name>/…: package.json (description, and "enforce": false when no company may
enforce it), skills/, commands/, hooks/, prompts/ and files/. The daemon turns the rows of a package that is ON into normal rows:
skills/ and commands/ keep their name, a hook or a prompt gets the name <package>-<name>, and a files/<f> row goes to
~/.config/kdmem/packages/<package>/<f> (never into a repository or ~/.claude). A normal row wins over a package row of the same path.
The daemon never overwrites or deletes a file in that folder that it did not write, so the .mp3 files of the user stay.
Who decides. The company sets the state of a package in its signed row packages.json, for example {"sounds": "on"}: enforced (on for
everyone), on ("default on": on, the person can turn it off) or off ("default off": off, the person can turn it on). A package with no
company setting is off. The person chooses on the Packages view of the fleet page: each package has a switch (and "Use the company
setting"). The page writes the row <user>|<package> of the table kd_package_choice (owner, pkg, choice = on or off; the server limits
the rows by owner). The daemon watches the table, keeps the own rows in ~/.config/kdmem/packages-choice.json, and applies them on every
computer of the person. The choice in device.json ("packages": {"sounds": "on"}) still works; the choice of the page wins over it. The
choice counts only when the company did not enforce the package. A package with "enforce": false (auto-approve) is never enforced:
enforced then counts as on. The last good company policy stays when the company layer is refused.
The company state is set by a company admin on the Packages tab of the /manage page (the fleet page shows it read only). The key never
goes into a browser, so the flow has four steps: (1) the page calls the admin service (/api/package-request, a token of an admin of that
company); (2) the service stores one row for each company and package in the table kd_company_request (only the service writes it; a member
reads the rows of its company); (3) the daemon on the computer that holds the company key (a key file whose public half equals the key that the
device pinned: company-<company>.key.pem, or for a company made by a signup the first device key) watches the table, builds the whole new
layer (the other rows stay, the version rises) and signs it; (4) it sends the layer to /api/publish-layer. That call needs no token: the
service checks the signature against the stored company key and accepts a higher version only. /manage shows "Waiting for the key computer"
until the layer shows the request, and has a Refresh button. If no computer with the key is on, the request waits. Make the table once with
bun daemon/company-cli.ts <env-file> request-table (a new company gets its grant when it is made).
The operator can still set a state by hand with bun daemon/company-cli.ts <env-file> package <company> <name> enforced|on|off|unset, on the
computer that holds the key. Make the personal table once with ... package-table.
A package of one company only (a custom package). A package in kd_assets is visible to everyone. A company can put a package into its
own signed layer instead: bun daemon/company-cli.ts <env-file> package-publish <company> <name> <dir> reads the folder <dir> (the same
layout as kdmem/packages/<name>/) and writes it as company/<company>/packages/<name>/…. It replaces the stored rows of that package and
keeps the other rows and the state row. Only the members of the company get the rows. The Packages page lists the packages of kd_assets
only, so set the state of a custom package with ... package <company> <name> enforced|on|off|unset. The source of such a package stays in the
repository of the company, not in this repository. Run it on the computer that holds the company key.
A custom package extends; it does not replace. Hooks of two packages both run (each has its own name, <package>-<hook>), and prompts add up.
Two skills with the same name are one file: a normal row wins, then the first package row, and nothing is logged. So a custom package does NOT
ship its own arc or fleet skill. It ships a prompt (for example <project>-arc) and hooks. The generic skills arc and fleet have a
section "Extensions of the project" that tells the model to follow such a prompt; where a project rule and a rule of the skill conflict, the
project rule wins. A hook of the company that has the same name as a generic hook replaces it in a project layer, and {"off": true} switches
it off.
The packages of this repository are in kdmem/packages/: auto-approve (the hook tool.check that allows every ask, except
AskUserQuestion, ExitPlanMode and EnterPlanMode) and sounds (play.ts plays a random .mp3 of the folder when Claude is done or
needs an answer; a Mac uses afplay, WSL and Windows use PowerShell, Linux uses ffplay) and arc and fleet (below). Publish with
bun daemon/assets-cli.ts <env-file> publish kdmem packages.
The package arc (how we work). It holds the generic form of the way we work in kinedb: test first, the issues and merges through
forge.ts, a local gate, hooks that enforce the rules, and the /arc skill. It needs bun and git. A project sets the tracker, the id
prefix and the gate steps in its own .claude/project.json.
The tracker of a company needs no tool of its own. Set tracker (kind, url, repo) and idPrefix in project.json; forge.ts then
allocates the ids (with a re-check against a second session) and checks the PR body. Three additions serve a company. forge.ts issue find <text>
lists the OPEN issues whose title holds the text. forge.ts pr state <branch|n> prints open, closed or merged. The setting
"pr": {"bodyLines": ["Direction"]} makes pr create refuse (exit 5) a body that closes an issue and lacks a Direction: … line. A line can name
paths, like the Docs line: {"name": "Direction", "paths": ["^tasks/direction\\.md$", "^tasks/direction\\.d/"]} refuses Direction: none
when the branch changes one of them, and a named answer needs such a change. pr merge checks the body again, with the body and the files from
the forge, because someone may edit the body after pr create.
pr create also keeps a closing keyword on a line of its own (Closes #N), because Gitea closes or reopens an issue on a keyword and #N
anywhere in the body, also after "does not close #3". Docs: none needs a reason of three words or more. With
"pr": {"docsPaths": ["^docs/", "^web/docs/"]}, the Docs: line names only docs files that the branch changes, and none is refused when the
branch changes one. The changed files are those of the branch against origin/<main> (the local main of a checkout is often old). The merge
policy is a setting too: "pr": {"mergeCheck": ["sh", "{packages}/kinedb/scripts/pr-job.sh", "{n}"]} makes
pr merge run that command INSTEAD of looking for a local gate PASS (exit 0 merges; any other exit code refuses the merge and is kept; {n},
{head}, {branch} and {packages} are filled in). "uids": {"U": {"dir": "docs/upgrade-paths.d", "pattern": "^U(\\d+)\\.md$"}} names a
folder of numbered files, and forge.ts uid next U prints the next free number (U8) over main and every branch or open PR that is not
merged, so that two parallel arcs never take the same number.
| Row | What it is |
|---|---|
prompts/rules.md |
The rules (CLAUDE.md in a generic form). The first prompt of a session carries it. |
skills/arc/ |
The /arc <id> skill and its TRAPS.md. |
hooks/gate-*.json |
Five hooks with protocol: "claude": PreToolUse (Bash, Edit, Write, MultiEdit, AskUserQuestion), PostToolUse (Bash), UserPromptSubmit, Stop and SessionStart. Each runs hooks/gate.ts. |
files/hooks/ |
gate.ts (the rules: a worktree for each arc, no commit on main, explicit git add, a commit that names its issue, protected paths, the question first, a waker for a promised action, the compact dump, and the owner question: issue answer must quote words that a human typed into THIS session, the label owner-question moves only through issue ask and issue answer, and a question that names an issue needs a prior issue ask). |
files/scripts/ |
forge.ts (issues and merges for a local, Gitea or GitHub tracker), gate.ts (the local gate). The texts of the gate name these scripts with their full path in the package folder. |
The tests of the package are in kdmem/package-tests/arc/ (not in the package folder, because publish sends every file under it).
bun test kdmem/package-tests/arc/ runs them. The mod cannot tell a start from a compact, so gate.ts finds the compact dump file of the session.
A project that wants more rules (kinedb itself) adds its own hooks, skills and prompts as rows of its project layer. A row of the project
replaces a package row of the same name, and {"off": true} switches a package hook off in that project.
The package memory (the notes of a project). A project keeps its lessons as notes: memory/ holds the rules and archive/ holds the
record of each arc. Each note is a Markdown file with frontmatter (description, component, kind, severity). The package delivers the
notes that fit the work. It needs bun and git, and it works only in a project that has .claude/kdmem/config.json (the components, the
aliases and the triggers of the project). It does not need the package arc. Until now these rules were a part of arc; a company that uses
arc must set memory to on to keep them.
| Row | What it is |
|---|---|
hooks/gate-*.json |
Three hooks with protocol: "claude": PreToolUse (Bash, Edit, Write, MultiEdit), PostToolUse (Bash) and UserPromptSubmit. Each runs hooks/gate.ts. |
files/hooks/gate.ts |
The rules. A note with a bad frontmatter is denied, with the template. The first edit under a component in a session is denied once, with the rules of that component. A command that matches a trigger gets a note. After a new commit comes the reminder to write an archive note. Each prompt gets the notes that fit its ids, components and words; a prompt that a machine wrote gets none. |
files/recall/kdmem.ts |
The store of the files and the recall. It has a command line: check, recall, gate, search and stats. |
The tests are in kdmem/package-tests/memory/. Today the notes are files on disk. There is no local database any more.
The notes on the kdmem server (B92, in progress). The table kd_memory holds one row for each VERSION of a note: the whole text of the file
(frontmatter included), the project, the id (the path of the file), the version, the sha, the author and the dates. A company reads and adds
rows; the grants hold SELECT and INSERT only, so a version is never changed or removed. Every member of a company reads all its notes. The
name of the project (the folder of the main checkout) is the namespace of the ids, so two projects may both have memory/never-assume.md;
the project "" is for the whole company. The operator makes the table once, and add-company does it for a new company:
bun daemon/company-cli.ts <env-file> memory-table.
Seed the notes (once). bun daemon/memory-cli.ts seed [<project-dir>] sends the notes that git tracks today (a file that git ignores or does not track is a private working file, for example a session handoff, and is never sent; --include-untracked sends it). It reads the folders of
.claude/kdmem/config.json, or of --config <file> (the older names seedRoots and seedExclude work too). It signs in with the key of the
device, as trace-cli.ts does, and uses the company of the device. --dry-run counts and writes nothing. A note that the server holds is
skipped, so the command is safe to run again, for example after a restore of the database; a changed file becomes the next version. A note
whose frontmatter recall does not accept is reported and sent too: nothing is lost. memory-cli.ts list [<text>] shows the notes of the
company, and get <id> prints the newest text of one note. The code is in daemon/memory.ts, the tests in daemon/memory.test.ts.
Work with the notes (B92). memory-cli.ts search <words> finds the notes of all projects whose id or text (any version) has the words, and
prints the matching lines of the newest version (--limit <n>, --project <name>). list --all lists all projects. The skill /kdmem
tells Claude how to answer a question from the notes: search with several words, read the whole note, answer from the text only, name the
note, say when two notes disagree, and say "no note answers this" when none does. The fleet page / has a Memory view: the list of the
notes (one project or all), a search on the server (id and text) that runs while you type (250 ms after the last key, 2 letters or more; a search takes 150 to 320 ms; an old answer never replaces a newer one), the versions of a note (newest first) and the text of each, Save as a
new version and Remove the note (a new version that marks the note as removed). The page only reads and inserts; the grants hold no
UPDATE and no DELETE, and the page text has none. The page checks the frontmatter (description, component, kind, severity) before it saves.
The tests are in daemon/page-memory.test.ts.
The notes as files on each computer (B92, stage 3a). A computer that has the package memory keeps the newest version of each note of the
company as a file: ~/.config/kdmem/memory/<project>/<id> (a note of the whole company is in _company). The daemon writes a file only when
its text differs from the server, and deletes the file of a removed note. A watch on kd_memory starts the sync at once, and the daemon
runs it every 5 minutes too. A project reads these files when .claude/kdmem/config.json has "store": "kdmem" (the default is "files":
the files of the repo, as before). The name of the project is the folder of the main checkout, or "project": "<name>" in the same file.
Each file gets the created time of its note (the add date from git, held by the server) as its mtime: recall ranks undated notes by it, so the
order is the same on every computer and the same as in kinedb (a file with the right text and a wrong time is only touched, never fetched again).
The hooks read only files, so they work with no network. The code is in daemon/memory-cache.ts, the tests in daemon/memory-cache.test.ts.
Write a note from a session (B92, stage 3b). In a project with "store": "kdmem" the hook of the package memory denies a Write or Edit of a note
file (memory/*.md, archive/*.md) and names the command. bun "$D" --note put <id> <file> [--project <name>] checks the frontmatter (the same
checks as the hook; supersedes looks in the cache folder), adds a new version on the server and puts the file in the cache folder at once.
--note remove <id> adds a version that marks the note as removed. With no network the write goes to ~/.config/kdmem/memory-queue.jsonl; the
daemon sends the queue in order BEFORE each sync of the files (so the sync never undoes a queued write), and a write that is sent twice is
harmless (an equal text is skipped). --note flush sends the queue now. The code is in daemon/memory-put.ts, the tests in daemon/memory-put.test.ts
and package-tests/memory/hook-gate.test.ts.
The package memory has the kinedb rules (B92, stage 1b). The parts of the kinedb memory (kine-memory/lib.ts and the hook) that the package
did not have are ported, with the tests of kinedb: lessonSummary and firstParagraph (the summary of a note that has no description),
deriveFrontmatter (the back-fill of a note with no frontmatter; a handoff note is a decision of severity 1, so it ranks last; the components
may have bodyHints, pathHints and ambient), the stop-word stems (fixed and making are stop words when fix and make are), triggers
that match in any letter case and never fail on a bad pattern, the gate memory-md-is-a-pointer (a commit that stages a MEMORY.md over
8192 bytes, with a line over 500 characters, with a bullet for a landed arc or a commit, or with more than 12 bullets that name a note is
denied; # gate:ok passes it), and the event log of the hook (~/.config/kdmem/memory-log/kdmem-YYYY-MM-DD.jsonl; kdmem.ts log [--tail n] [--session s] [--event e] prints it; derive <path> [file] prints the frontmatter that a note would get). The labelled prompts of
kinedb are in package-tests/memory/recall-labels.ts. labels.test.ts runs them over the real corpus when KDMEM_LABELS_CONFIG names the
config of the project and the daemon keeps its notes as files (CI servers have neither, so the test is skipped there). On the kinedb corpus:
kdmem recall labels: 24/24 hold.
Parity with the repo (B92, stage 4). package-tests/memory/parity.test.ts answers the question that comes before the note files of a
project are removed from its git repo: does the project lose anything? It runs only when KDMEM_PARITY_CHECKOUT (a fresh clone or checkout),
KDMEM_PARITY_KINEDB (the repo with .claude/kine-memory/) and the cache folder of the daemon exist. It checks the data (the same ids and the
same text in the cache folder as in the repo; nothing else) and the entries (kinedb's entryFromFile and the package parser make the same entry
of each note). The first run found three differences, all fixed: the title keeps no backticks (240 notes), a component that is not in the config
makes the note invalid (3 notes), and with the store kdmem the created time is the mtime of the file (the git add date, set by the daemon),
not the date in the file name. On 2026-10-19: data 2876 = 2876, entries 2838 = 2838, no difference. The seed sends only the files that git
tracks (it had sent 17 ignored session handoffs; they are marked as removed).
The package fleet (a queue of arcs in the background). /fleet <id> … makes a session the supervisor of /arc workers. Each worker
is a claude --bg session. The supervisor starts them up to a limit that the owner gives, watches them, wakes them, queues the issues that
a worker filed and refills the queue. It needs the package arc: the issues come from its forge.ts, the ids from the idPrefix of
the project, and the worker is /arc. It needs bun, git and claude.
Several computers (B92, owner 2026-10-20). When the owner has more than one online computer that lists a folder of the project
(the mod tool spawn_targets), /fleet asks ONE question: work as a global leader? On yes, the leader starts one manager session on each
computer with the mod tool spawn (/fleet --manager <leader session id> <ids>), keeps the owner's orders for each computer (slots, hold)
and talks to the managers with send. A manager works as /fleet does on one computer, with its own ledger, queue and slots, and does the
local proofs. With one computer nothing changes. The text is section 0a of the skill; there is no new command in fleet.ts.
One session, one issue (B118, owner 2026-10-21). "ONE session = ONE issue, start to merge. A blocker or follow-up gets its OWN issue and
its OWN new session. Never send a running worker a second B-id, even if the leader or the worker proposes it." The hook
fleet-one-session-one-issue denies a SendMessage or a kdmem send to a worker fleet B<n> whose text gives another B-id as work (a line
that only reports a state, such as "B7 merged" or "blocked by B7", passes). The second rule: "A session that waits (waiting list, pr merge --wait,
blocked on a merge) holds NO editing slot; count only sessions that edit now." So takenSlots does not count a worker whose state is blocked,
and the slot line says "blocked B2 does not count". Both rules are in section 0 of the skill and in the prompt prompts/one-issue.md. The
words of the compact prompt stay as they are (owner 2026-10-20).
The worker keeps its issue (B119, owner 2026-10-22). The same rule from the side of the worker: "ONE agent can only work on ONE thing."
/arc <id> binds a session to its issue (the id of its first /arc <id> prompt of a human). The hook one-session-one-issue of the package
arc denies a commit whose subject starts with another id, issue claim of another id, and a branch or worktree arc/<other id>; it blocks a
second /arc <other id>; and it adds a reminder when a prompt gives a second id as work. The kdmem mod sends no transcript_path, so the hook
finds the transcript by the session id. fleet.ts wake refuses a message that gives the worker another id (exit 4). A session that did not
start with /arc <id> has no bound issue, and no gate judges it. The rule is in the skill arc (start and section 6), in the prompt rules
of the package arc and in section 0 of the skill fleet.
The daemon logs in again after a lost connection (B121, owner 2026-10-22). The client SDK has no reconnect (kinedb/kinedb#781, B861). The daemon loop already logged in again after a failed pass, with a fixed wait of 5 s. Now the wait grows: 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s for ever (kdmem/daemon/reconnect.ts). A good pass resets it. A connect or login that gets no answer ends with an error after 20 s, so the loop tries again. Remove reconnect.ts when the SDK reconnects by itself.
The hook judges the entered worktree (B122). After EnterWorktree the hook gets the launch dir as cwd, so no-commit-on-main read the branch of the main checkout and denied a plain git commit in the worktree (kinedb/kinedb#5192, #5365). The gate of the package arc now reads the transcript (by the session id): the last EnterWorktree that no ExitWorktree ended gives the start dir, when it is a worktree of the same repository (enteredWorktree in hooks/lib/transcript.ts). A failed call and the calls of a subagent do not count. The repo's own .claude/hooks/gate.ts is not changed.
An interactive session has its own row (B120). An interactive session has no job id, so its id is empty. All interactive sessions of one computer then shared the row key <user>|<machine>|: the fleet page showed one of them, and the daemon wrote the rows again at every pass. Now the key of a row is <user>|<machine>|<id>, or the sessionId when id is empty (rowKey in agents-publish.ts). The column id stays empty on purpose: the page stops and removes a session by that id, and an interactive session has no job. A job that was resumed as an interactive session keeps its job folder with the same sessionId. offlineSessions now skips such a job, so the page shows the live row only.
bettercompact finds a dump file that Bash wrote (B123). The mod used to see the dump file only when a Write, Edit or MultiEdit named it. A session that wrote it with cp or cat >> in Bash never compacted: 15 of 15 calls with a Write compacted, the 4 calls with only Bash did not (two sessions, 2026-10-23). Now, at each turn end in the phase asked, the mod also reads the time of the file at the path that the tool named. A file that changed after the call of the tool is the dump file (an older file is a leftover). The approval switch now ends after 40 turn ends with no dump file. A give-up and a hook veto (skip) write a trace row (compact-give-up, compact-skipped). A session that has no turn gap (kdmem issue B124) stays as it is, by decision of the owner: the agent decides when it compacts, and a person can type /compact.
A daemon that cannot publish ends itself (B125, owner 2026-10-23). The heartbeat file proves that the loop runs, not that it publishes: a daemon with a lost or hung connection looked alive, so no session started a new one. Now the daemon writes publish-ok.json at each good pass. When no pass is good for 10 minutes, the daemon ends with exit 4 and frees its lock (kdmem/daemon/watchdog.ts). The heartbeat then goes stale, and the next session of Claude Code starts a new daemon with --ensure. The lock also changed: a live pid holds the lock only when it wrote heartbeat.json, or when the lock is less than 60 s old. A program that got the pid of a dead daemon no longer blocks the new daemon (isPublisher in publisher-lock.ts). Not built: a launchd or systemd service. A person must ask for it; it is never a default of the setup.
Each trigger of the mod checks the daemon (B126, owner 2026-10-23). The mod used to read heartbeat.json only at a session start and at a turn end, at most every 5 minutes. A worker with one long turn, and an idle session, were not checked. Now a prompt, a tool call, a turn end and the 2 s message tick call one check (ensureIfDue in register.js). The check runs --ensure when the heartbeat is stale. An in-memory limit of 30 s (checkEveryMs in mod-config.json) keeps the cost at one file read, and decide() still allows one start in 60 s. With B125 a daemon that cannot publish ends itself after 10 minutes, and the next check starts a new one. An arc hook that runs --ensure would repeat this check, so it is not built.
| Row | What it is |
|---|---|
skills/fleet/ |
The /fleet skill (generic: no tool or id of one company). |
files/scripts/ |
fleet.ts (the commands limit, start, watch, status, wake, next, queue, hold and more), transcript.ts, session-procs.ts, url-path.ts. The ledger and the queue files are in $TMPDIR/kdmem-fleet/. |
hooks/gate-pre.json |
PreToolUse (Bash, AskUserQuestion, SendMessage, the kdmem tool send): a worker with a live supervisor sends its question with SendMessage; a worker on waiting.txt commits and pushes no code; a message to a worker fleet B<n> that gives another B-id as work is denied (B118, below). |
prompts/one-issue.md |
The two rules of the owner (B118) in every session of the package. |
hooks/gate-stop.json |
Stop: the supervisor of a live worker holds a watch, wakes a worker with unread notifications and fills a free slot. |
The kdmem mod sends no background_tasks and no transcript_path in a Stop event. files/hooks/fleet-gate.ts reads the background tasks
from the transcript of the session (a background Bash call, its id, and the <task-notification> that ends it). When it cannot read them,
the fact is unknown and the turn ends: an unknown fact never blocks. The tests are in kdmem/package-tests/fleet/. Three of them
(*.selftest.ts) run themselves and call process.exit; they are not named *.test.ts, because bun test would stop at the first of them.
run-self-running.test.ts runs each one as a program.
This folder is NOT copied into a project by setup.ts. It is the product, and it lives here
so that the kinedb repository stays free of it.
| Path | What it is |
|---|---|
daemon/ |
The publisher (agents-publish.ts), the device key and login (setup.ts, device.ts), the signed delivery of skills and code (assets-*.ts, release-sign.ts, code-update.ts), the company admin (company-*.ts). bun test kdmem/daemon/ runs its tests. |
mod/ |
The kdmem-usage mod. claude plugin test kdmem/mod runs its tests. The mod writes the plan limits AND the context of each session (tokens and window) to ~/.claude/kdmem-usage/<session>.json. The fleet page shows the percent of the context window from it, so a computer needs no status line script; a personal status line log, when it exists, wins. A tool call writes it too (at most every 20 seconds), because a fleet worker runs one long turn and session.measure fires only when a turn ends. The page shows the percent and the current use (12% · 117k), never the size of the window as a number (owner 2026-10-19). The daemon still writes the size of the window to the column fleet_agents.ctx_window (run bun daemon/company-cli.ts <env-file> agent-window ONCE as root BEFORE the release of the daemon). |
.claude-plugin/marketplace.json |
The marketplace that offers the mod. |
skills/ |
The skills that the database delivers to every machine (/kdmem-setup and /kdmem, both global). Publish with bun daemon/assets-cli.ts <env-file> publish kdmem skills/kdmem skills/kdmem-setup. Remove a row with ... delete <path>. |
site/ |
The fleet page and the admin page. sh deploy-site.sh deploys them. |
deploy-*.sh, provision-server.sh, Caddyfile |
The server side. |
q*.ts, q*.sh |
Probes and end-to-end checks from the design phase. |
DESIGN.md |
The design. |
/kdmem (B14) is for a person who wants to ask or change kdmem in chat: /kdmem, /kdmem scope except . or /kdmem why is my session not listed. The skill
explains what kdmem is and lists the commands of the daemon file and of the template repository (scope, password, spawn, trace). It reads the status of
the computer. It changes only what the person asked, and it never reads a key file or types a password (the person runs that command with !).
The daemon and the skills of a machine come from the database, signed with an ed25519 release
key. They never come from a git pull. The private key stays on the owner machine
(~/.config/kdmem/release.key.pem).