Experimental kgai add-on for Claude Code with function hooks: checks each code-editing turn for a structural decision on the side, records it without a line in…

Your dev team already decided this. Nobody remembers why. The why behind your code lives in people's heads and lost chat threads — and every AI coding session starts from zero. kgai is the missing shared decision memory: add it to your AI workflow once, and it captures and recalls decisions by itself while you work. Team sync is opt-in (your own S3; git experimental).
<img src="docs/demo.gif" alt="kgai demo: the agent rules out Redis with a reason and kgai records it; a brand-new session checks the decision log before coding and keeps the constraint" width="560"> <a href="https://kgai.dev">kgai.dev</a> · local-first — your code never leaves · opt-in team sync (your own S3) · zero upkeep · MIT
While you and your AI change code, kgai records the structural decisions into a small, searchable knowledge graph — what changed, why, and what was rejected — automatically, without you asking. Before touching an area, your AI checks what was already decided. Nothing is ever overwritten, so you can always ask how did this get this way? and get the full story.
Alice ships product search. Her agent records the decision — by itself:
✓ recorded “Sold-out products stay visible in search” d_1e67c079
supersedes d_1f7c715a — kept in history
Weeks later, QA is testing and hits something odd: “sold-out products show up in search — bug?” One question to the graph:
$ kg search "why are sold-out products visible in search"
● Sold-out products stay visible in search decision
Hiding sold-out items dropped organic landing traffic ~40%. Keep them visible as 'unavailable'.
→ product-search
And the whole evolution — the dead end included:
$ kg history "feature:product-search"
feature:product-search — 2 decision(s), oldest first
2026-05-02 Search hides sold-out products superseded
why: Sold-out items clutter the results; hide them until restock.
2026-07-16 Sold-out products stay visible in search ● current
why: Hiding sold-out items dropped organic landing traffic ~40%.
Not a bug — decided on purpose. Ticket closed in two minutes, no dev interrupted.
# install from GitHub (public marketplace lives in this repo)
claude plugin marketplace add kgaidev/kgai
claude plugin install kgai@kgai-marketplace
The plugin sets itself up the first time you start a Claude Code session with it enabled — installing a plugin only downloads files, so nothing runs until then. Prebuilt engine binaries ship for Linux (x86_64, aarch64) and macOS (Apple Silicon + Intel), so you need neither Go nor a C compiler (the engine goes to ~/.kgai; falls back to building from source if needed). On Windows, run Claude Code inside WSL — there is no native engine, and the installer says so rather than failing obscurely. Then just work normally — Claude reads and records decisions on its own. To record or query by hand:
/kgai:kg-ask "Invoice" # what's decided about this area, and why
/kgai:kg-decision # record a decision yourself
/kgai:kg-history # how something evolved
The same setup also puts kg in ~/.local/bin, so the CLI works in your own terminal and not only inside Claude Code. If that directory isn't on the PATH your terminal builds — which the installer confirms by asking your login shell, not just by grepping that shell's profile — it appends one marked PATH line to the profile the shell actually reads. Open a new terminal and kg version answers; if anything about that didn't work, the session's status line says so instead of reporting success. Once the marked line is in place the check is not repeated, so wiring that breaks later (say, a new .bash_profile that shadows the .profile holding the line) goes unnoticed — delete the marked line and the next session re-verifies from scratch.
The same decision memory runs on two other agents. It is the same engine and the same capture behaviour — recall before a change, record the decision as you work, sync without merge conflicts — wired to each host's plugin system.
Codex CLI (needs Codex ≥ 0.150) installs this repository directly, exactly like Claude:
codex plugin marketplace add kgaidev/kgai
codex plugin add kgai@kgai-marketplace
Codex asks once to trust the plugin's hooks; until you accept, the skill and commands work and automatic capture does not.
Gemini CLI installs the extension:
gemini extensions install https://github.com/kgaidev/kgai-gemini
A personal Google login is no longer eligible for the Gemini CLI — set a GEMINI_API_KEY from AI Studio (its free tier is enough). Gemini asks before each kg command; allow kg in the workspace policy to stop the prompting.
On both, the engine installs itself to ~/.kgai on first session, same as on Claude Code.
To get kg on a machine where the plugin never ran, or to repair an installation:
curl -fsSL https://raw.githubusercontent.com/kgaidev/kgai/main/scripts/install.sh | bash
That is the plugin's own installer, run standalone: same engine, same launcher, same locations — see the FAQ for what it puts where and how to remove it. Once the plugin runs too, both keep themselves current at every session start.
The store is per-project and everything is picked up automatically — it is created in <project>/.kgai/store at session start (and by the first recorded decision, and added to the project's .gitignore); your name on recorded decisions comes from git config user.name. Read commands never create a store: where nothing has been recorded they answer with an empty result and a note instead of minting a stray empty graph. The one exception is a repo shipping a committed .kgairc you have not yet decided on: while that approval is pending the local store is not created either, so approving its team store later does not leave a stray one behind (see docs/CONFIGURATION.md). To set it up explicitly up front:
cd your-project
kg init
A brand-new graph is empty, so the first real value comes from seeding it with what you already know. Two ways that work well:
kg ingest.kg ingest batch and give each decision its real date so the timeline is honest (see Importing past decisions).Then check what the graph knows: kg context (whole picture), /kgai:kg-ask "<area>". From that point on, day-to-day capture is automatic.
Seeding the graph with decisions that were really made earlier? Give each one a date (YYYY-MM-DD or RFC3339) so the history and kg as-of <date> reflect the real timeline, not the import time:
{ "decision": { "title": "…", "date": "2025-03-15", "mutations": [ … ] } }
| You want to… | Command / slash |
|---|---|
| See what's decided about an area, and why | /kgai:kg-ask · kg context --about X / --paths a,b |
| Record a decision | /kgai:kg-decision · kg ingest |
| Review a task, graph-aware (read → review → capture) | /kgai:kg-review |
| See how something evolved | /kgai:kg-history · kg history "feature:Invoice" |
| See the whole picture at a past date | kg as-of 2026-01-01 |
| Resolve conflicting decision branches | /kgai:kg-conflicts |
| Raw query (power users) | /kgai:kg-query · kg query "…" |
Capture is hands-off, backed by two layers: the bundled knowledge-graph skill makes the model record structural decisions on its own, and a Stop hook catches the case where it edits code but forgets — nudging it to record before finishing. Trivial work (renames, formatting, bug fixes) records nothing, so the graph stays signal, not noise.
In headless testing this held up across models: structural refactors auto-recorded reliably; when the model was blocked from recording on its own, the hook still captured every time; trivial edits recorded nothing even when nudged.
The Stop hook works by continuing the turn, so after every turn that edited code the model answers it in the transcript, usually with a line like "No structural decision, nothing to record." kgai-mod is an optional second plugin for Claude Code builds with function hooks (Claude Code Mods, early access) that asks the same question on the side. When the turn ends, it forks the session as a tool-less side question. That fork reuses the session's prompt cache and adds no row to the transcript. The fork answers with a kg ingest payload or NONE, and the mod validates the payload with kg ingest --dry-run and then records it. You see something only when a decision was recorded: a status line kgai: recorded "<title>" for a few seconds. A failed check stays out of the transcript. It goes to the debug log, and you get at most one toast per session.
/plugin install kgai-mod@kgai-marketplace
It also shortens kgai's own tool calls in the transcript. Each kg search, kg context, kg ingest, kg sync and kgai skill call shows as one dim line, such as kgai: reading the graph or kgai: recorded "Invoice renders standalone", instead of the command, its JSON payload and the engine's JSON answer. Only the drawing changes: the model and the stored transcript still have everything. A call that fails shows in full, and so do kg init, kg config and the kg-trust skill.
It needs the kgai plugin installed as well, because that plugin still installs the engine and ships the skills. With the mod active, the mod takes the kgai Stop hook's capture instruction out of the hook result, so the turn is not continued. Both kgai Stop commands still run, so team sync is unchanged. Uninstall the mod (/plugin uninstall kgai-mod@kgai-marketplace) and the classic nudge is back. Nothing changes for anyone who does not install it, and Codex, Gemini CLI and Claude Code builds without function hooks keep using the classic hooks.
The capture option (in /config, or pluginConfigs in settings) picks what it does:
capture | What happens at the end of a turn that edited code |
|---|---|
auto (default) | records a structural decision silently and shows the status line |
confirm | shows the decision above the prompt with Record / Skip; the band goes away with the next prompt |
off | nothing, and the classic nudge stays on |
The compact option (default on) controls the one-line kgai rows. Turn it off to see every kg command and its output in full.
Surfaces: the status line, toast and band work in the terminal and the desktop app's Code tab. In this Claude Code build the band above the prompt is drawn only on those two surfaces, so confirm records as auto does in sessions that draw on neither, such as the VS Code extension alone or a headless session (claude -p, the SDK, custom stream-json hosts). A headless session also gets the result as one ui_log line, kgai: recorded …, which the host can show.
The mod's API is early access and may change between Claude Code releases, so treat kgai-mod as experimental. Its source and tests are in mods/kgai-mod. To try a local checkout, run claude --plugin-dir mods/kgai-mod with the kgai plugin installed. claude plugin test mods/kgai-mod runs its tests.
The kgai extension for VS Code (and the editors built on it — Cursor, Windsurf) puts the project's decisions in the sidebar: search and exact filters over the log, elements with their history, contested elements, people, an overview with charts, and the live graph on a canvas with the decisions as a layer. It finds the store as kg does and only reads it — it ships its own small reader, so nothing else is installed and an open editor can never collide with the engine. It lives in editors/vscode and installs from the VS Code Marketplace or Open VSX — search kgai in the Extensions view.
<img alt="The kgai graph in VS Code: a demo shop's elements coloured by kind and sized by the decisions that shaped them" src="editors/vscode/media/screenshots/graph.png">
The picture shows examples/acme-shop, a fictional shop's checkout and billing as three people shaped it over seven months — 61 decisions, one still contested — recorded with kgai so you can see the tools on a real story. Open the folder (copied out as a folder of its own) in VS Code with the extension, or run kg in it.
The nodes are domain elements (features, services, business objects) joined by links; a decision is an immutable event that reshapes that graph and carries who/why/when. The chain of decisions is the history; the live graph is always the current shape.
It's event-sourced: an append-only, content-addressed decision log is the source of truth, projected into an embedded LadybugDB/Kuzu property graph (queryable with Cypher) that can be rebuilt from the log at any time. Identity is a deterministic hash of an element's kind+name, so recording the same thing twice converges on one node with no coordination.
Full design: docs/ARCHITECTURE.md.
Settings resolve in three layers, most specific first — the same shape in every file, the way git config and npm do it:
| Layer | File | Who it is for |
|---|---|---|
| session | <store>/kg.config.json | this install; never committed (holds the cloud token) |
| project | <repo>/.kgairc | committed — the repo's default for everyone who clones it |
| global | ~/.kgai/config.json | this machine; written only when you ask for it |
All three files hold the same JSON shape — only the location decides the layer, the way npm layers .npmrc. Every key overrides — nothing merges — so kg config can always name the one layer a value came from.
| Key | What it is | Where it may be set |
|---|---|---|
prompt | your capture rules, given to the agent | any layer |
store | where the decision log lives | project, global |
remote | sync target | session, global |
cloud_url | kgai cloud broker | session |
remote and cloud_url are deliberately not taken from the committed file. Syncing belongs to the store, not to one repo (several repos can share one store, and one log cannot push to two places), and cloud_url is the address your install-local token authenticates against. Identity (install_id, actor, machine) and the token itself do not layer at all.
kg config # every layer, each effective value, its source
kg config set --project prompt "…" # commit a capture rule for the whole repo
kg config set --global prompt - # multi-line value from stdin
kg config unset prompt # clear it in this install; broader layers return
kg remote s3://team/kg # sync target for this store
A committed .kgairc decides nothing until you approve it. It is the one layer you did not write — it arrives with git clone, from whoever made that repository — so kgai ignores it until you say otherwise, and asks again whenever what it asks for changes (a teammate's commit, a git pull).
You approve it in the session: Claude shows you the store path and the capture rules the file asks for and waits for your answer — /kgai:kg-trust starts that on demand. It never approves on its own initiative, whatever the file says. By hand it is:
kg trust --show # what this repo's config asks for — approves nothing
kg trust # approve it on this machine
kg trust --dismiss # don't want it: stop being prompted (keeps the local store)
kg trust --revoke # withdraw an approval or a dismissal
Until then kg config reports it as pending_approval and the session says so instead of loading the rules — nothing is blocked meanwhile, and the project's own local store is not created until you decide (approve → the team store; keep working → it is made lazily by your first recorded decision), so approving later does not leave a stray one behind. Don't want the repo's config at all? kg trust --dismiss records that so you are not asked again. What you approve is what the file asks for — the values of prompt and store — not its bytes: reformatting it asks nothing new, changing a rule asks again, and one approval covers every repo asking for the same thing (a company standard is accepted once per machine, and an inherited approval is announced once). Approvals live in ~/.kgai/trusted.json: per machine, per user, never synced. They cannot live in .kgairc itself — that file is committed, so one person's approval would travel to everyone who clones it, which is exactly what the step exists to prevent.
docs/CONFIGURATION.md is the full model: every key, which layer may set it and why, what a committed config can and cannot cause, and the residual risks.
Project capture rules (prompt). Whatever you put in this key is given to the agent at the start of every session, in front of the knowledge-graph skill — the place for conventions like "elements are named after bounded contexts" or "every decision carries the ticket ref". Commit it in .kgairc and the whole team's agents record the same way; keep it in the global layer and it is just yours. The rules can only add to the skill's rules, never relax them, and they reach the model framed as configuration data inside a delimiter the file cannot forge — not as instructions from you. Anything past 8,000 bytes is truncated, because it is paid for at every session start.
| Env | Meaning | Default |
|---|---|---|
KGAI_STORE | knowledge-graph store location (beats the store setting) | <project>/.kgai/store (per-project) |
KGAI_PROJECT | project root used to locate the store | git top-level (worktrees → main worktree) |
KGAI_HOME | engine binary + native lib home | ~/.kgai |
KGAI_ACTOR | your name on recorded decisions | git user / $USER |
KG_RELEASE_BASE | prebuilt download base | this repo's latest release |
By default the KG is per-project: each project gets its own graph in <project>/.kgai/store (auto-created on first use and added to the project's .gitignore). The engine binary itself is shared in ~/.kgai.
Several repositories, one graph. In a company the same people move between shop-api, billing and web, and decisions cross those lines — "payments moved out of shop-api into billing" is one decision about two repos. Enroll a repository into a shared log once, commit it, and every clone follows without any per-developer setup:
kg config set --project store '${HOME}/kgai' # this repo joins the shared graph
kg trust # approve it here too — writing a .kgairc
# does not approve it, not even for its author
git add .kgairc && git commit -m "kgai: record into the shared company graph"
# on every machine that clones it, once:
kg trust # approve what the repo asks for
Approval is per machine and per configuration, so the author approves once as well. Skip that second line and your own repo stays pending: every session says an approval is waiting, and the store you just set does not apply — decisions keep going to the local store until you accept the file.
${HOME}, ~ and repo-relative paths keep the committed value portable across machines. Repos you never enroll keep their own log, so a side project never lands in the company graph. kg config reports the resolved store_root and which layer decided it — details, trade-offs and the migration path in docs/SHARED-STORE.md.
Branches and worktrees. The graph is per project, not per branch. The store lives outside your project's git (it has its own repo and sync cycle), so git checkout never changes it: a decision recorded on a feature branch is visible from main immediately, and decisions never cause merge conflicts in your code. git worktree follows the same rule — every worktree of a project resolves to the main worktree's store, so switching to a worktree does not hand you an empty graph. The configuration follows the store: the .kgairc that governs is the one in the main worktree, so a branch cannot repoint its worktree at a different graph; edits take effect once merged and checked out there. The flip side is that a decision recorded on a branch you later abandon stays in the graph; record a superseding decision to retract it.
Share one memory across the whole team — humans and AIs alike:
kg init --remote s3://your-bucket/team-kg # or later: kg remote s3://your-bucket/team-kg
kg sync # or /kgai:kg-sync from Claude Code
Instead of configuring every project, you can set one global default — used by any project that has no remote of its own:
kg remote --global "s3://your-bucket/kg/{project}" # {project} → the project dir's name
kg remote # show every layer and the effective value
kg remote none # opt THIS store out of the global default
kg remote --unset # back to the global default
A store's own remote always wins over the global one — and a committed .kgairc cannot set it at all, because syncing belongs to the store rather than to one repository (see docs/CONFIGURATION.md). Without the {project} placeholder the global value is used verbatim, meaning every project syncs into one shared graph — do that only on purpose. kg status shows which remote is in effect and where it came from (remote_source: session | global | disabled).
Once a remote is configured, syncing is automatic. A plugin h
hooks/register.tsx 326 lines1// kgai-mod: the end-of-turn capture check of the kgai plugin, run on the side.
2//
3// The kgai plugin's Stop hook (hooks/auto-capture-stop.sh) continues a turn that edited
4// code with an instruction to record any structural decision, so the model always answers
5// it with a visible line, most often "nothing to record". This mod asks the same question
6// as a fork of the session ($.model.fork: no tools, no transcript row) once the turn is
7// over, ingests what it proposes, and removes that one instruction from the classic Stop
8// result. The kgai plugin is still required: it installs the engine and ships the skills.
9//
10// It also draws kgai's own tool calls (kg search, kg ingest, the kgai skills) as one dim
11// line each instead of the command and its JSON; see rows.ts.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { KgaiModPending } from '../types'
16import { kgRow } from './rows'
17
18const pending = atom({ plugin: 'kgai-mod', key: 'pending' } as const, null)
19const hasToastedFailure = atom({ plugin: 'kgai-mod', key: 'hasToastedFailure' } as const, false)
20
21// The kgai skill that records a decision; any `kg ingest` the model runs counts the same.
22const RECORD_SKILL = /(^|:)kg-decision$/
23const INGEST_COMMAND = /\bkg\s+ingest\b/
24
25// How auto-capture-stop.sh starts its nudge. tests/hooks-contract.sh keeps the two equal.
26export const CLASSIC_NUDGE_PREFIX = 'Before you stop: this turn edited code.'
27
28const STATUS_MS = 6000
29const FAILURE_TOAST = 'kgai: capture check failed, see --debug'
30
31export const CAPTURE_PROMPT = `kgai end-of-turn check. This is a side question: your answer is not shown to the user, and you cannot use tools.
32
33The turn that just ended edited code. Decide whether it made a STRUCTURAL decision about the codebase that belongs in the kgai knowledge graph, applying the knowledge-graph skill's rules strictly:
34
35- DO record: splitting, merging or moving a module or feature; changing a dependency or an ownership boundary; deciding how something is exposed or rendered; deprecating or replacing a prior structural decision; renaming a domain element (its canonical name changes).
36- DON'T record: code-level renames of files, functions or variables; behavior-preserving refactors; formatting; bug fixes that restore intended behavior; pure implementation details; analyses, reports or recommendations nobody acted on. When in doubt, don't.
37
38Answer with exactly one of these and nothing else (no prose, no code fence):
391. The single word NONE.
402. The JSON payload for \`kg ingest\`: {"decision": {...}} or {"decisions": [{...}, ...]}. Each decision has "title" (one line), "rationale" (2-3 sentences on why) and "mutations", and attaches to at least one element. Mutation ops: {"op": "upsert_element", "kind": "feature", "name": "Invoice", "props": {...}} (props optional), {"op": "add_link" or "retire_link", "from": "kind:name", "link": "PART_OF", "to": "kind:name"}, {"op": "set_prop", "element": "kind:name", "key": "...", "value": "..."}. Reuse element names already used in this session exactly. Leave out "author".`
41
42type Capture = 'auto' | 'confirm' | 'off'
43type Proposal = { kind: 'none' } | { kind: 'payload'; payload: string; title: string } | { kind: 'invalid'; why: string }
44
45// Reads the fork's answer: NONE, or a `kg ingest` payload (a stray code fence tolerated).
46export const parseAnswer = (text: string): Proposal => {
47 const body = text
48 .trim()
49 .replace(/^```[a-z]*\s*/i, '')
50 .replace(/\s*```$/, '')
51 .trim()
52
53 if (/^NONE\b/i.test(body)) {
54 return { kind: 'none' }
55 }
56
57 let parsed: unknown
58 try {
59 parsed = JSON.parse(body)
60 } catch {
61 return { kind: 'invalid', why: 'answer is neither NONE nor JSON' }
62 }
63
64 const titleOf = (d: unknown) =>
65 typeof d === 'object' && d !== null && typeof (d as { title?: unknown }).title === 'string'
66 ? (d as { title: string }).title
67 : undefined
68 const root = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as { decision?: unknown; decisions?: unknown }
69 const titles = Array.isArray(root.decisions) ? root.decisions.map(titleOf) : [titleOf(root.decision)]
70
71 if (titles.length === 0 || titles.some(t => t === undefined)) {
72 return { kind: 'invalid', why: 'payload has no decision with a title' }
73 }
74
75 const title = titles.length === 1 ? `"${titles[0]}"` : `${titles.length} decisions`
76
77 return { kind: 'payload', payload: JSON.stringify(parsed), title }
78}
79
80let statusTimer: { cancel: () => void } | undefined
81
82// Tool rows drawn as one line: their result block under them is left out too. A ToolResult
83// carries no input to tell a kg call by, and its ToolUse row is always drawn first.
84const compacted = new Set<string>()
85const COMPACTED_MAX = 500
86
87function compact(id: string) {
88 compacted.add(id)
89 if (compacted.size > COMPACTED_MAX) {
90 const oldest = compacted.values().next().value
91 if (oldest !== undefined) {
92 compacted.delete(oldest)
93 }
94 }
95}
96
97function debug($: EngineInterface, text: string) {
98 return $.ui.log(`kgai-mod: ${text}`, { to: 'debug' })
99}
100
101async function fail($: EngineInterface, why: string) {
102 debug($, `capture check failed: ${why}`)
103 if (!(await read($, hasToastedFailure))) {
104 await update($, hasToastedFailure, () => true)
105 $.ui.toast(FAILURE_TOAST)
106 }
107}
108
109// The engine as the bash hooks find it: $KGAI_HOME/bin/kg (default ~/.kgai), else PATH.
110async function kg($: EngineInterface) {
111 const home = (await $.env.get('KGAI_HOME')) ?? `${(await $.env.get('HOME')) ?? ''}/.kgai`
112 const libs = `${home}/lib`
113 const own = await $.fs.stat(`${home}/bin/kg`).catch(() => undefined)
114 const bin = own?.kind === 'file' ? `${home}/bin/kg` : 'kg'
115 const ld = await $.env.get('LD_LIBRARY_PATH')
116 const dyld = await $.env.get('DYLD_LIBRARY_PATH')
117 const env = {
118 LD_LIBRARY_PATH: ld ? `${libs}:${ld}` : libs,
119 DYLD_LIBRARY_PATH: dyld ? `${libs}:${dyld}` : libs,
120 }
121
122 return (args: string[], stdin: string) => $.process.run([bin, ...args], { stdin, env, timeoutMs: 20000 })
123}
124
125// Dry run first (resolves names, refuses a bad payload without writing), then for real.
126async function ingest($: EngineInterface, payload: string, dryRunOnly = false) {
127 const run = await kg($)
128 for (const args of dryRunOnly ? [['ingest', '--dry-run']] : [['ingest', '--dry-run'], ['ingest']]) {
129 const out = await run(args, payload)
130 if (out.exitCode !== 0) {
131 return `kg ${args.join(' ')} exited ${out.exitCode}: ${(out.stdout || out.stderr).trim().slice(0, 300)}`
132 }
133 }
134 return undefined
135}
136
137async function announce($: EngineInterface, title: string) {
138 const line = `kgai: recorded ${title}`
139 debug($, line)
140 // A session nothing draws on (claude -p, an SDK or stream-json host) gets the line as
141 // ui_log; a drawn one keeps its transcript clean and shows a passing status line.
142 if ((await $.session.surfaces()).length === 0) {
143 $.ui.log(line)
144 return
145 }
146 $.ui.status(line)
147 statusTimer?.cancel()
148 statusTimer = $.clock.after(STATUS_MS, () => $.ui.status(undefined))
149}
150
151async function check($: EngineInterface, capture: Capture) {
152 if (await $.env.get('KGAI_DISABLE_HOOKS')) {
153 return
154 }
155 const reply = await $.model.fork({ prompt: CAPTURE_PROMPT })
156 if (!reply.isAnswered) {
157 return fail($, `fork not answered (${reply.reason})`)
158 }
159
160 const proposal = parseAnswer(reply.text)
161 if (proposal.kind === 'none') {
162 return debug($, 'no structural decision this turn')
163 }
164 if (proposal.kind === 'invalid') {
165 return fail($, proposal.why)
166 }
167
168 // confirm needs a band to draw on; this build raises AbovePrompt on the terminal and
169 // the desktop alone, so anywhere else (headless included) it records as auto does.
170 const surfaces = await $.session.surfaces()
171 const hasBand = surfaces.some(s => s === 'terminal' || s === 'desktop')
172 if (capture === 'confirm' && hasBand) {
173 const invalid = await ingest($, proposal.payload, true)
174 if (invalid !== undefined) {
175 return fail($, invalid)
176 }
177 const proposed: KgaiModPending = { title: proposal.title, payload: proposal.payload }
178 return update($, pending, () => proposed)
179 }
180
181 const error = await ingest($, proposal.payload)
182 if (error !== undefined) {
183 return fail($, error)
184 }
185 return announce($, proposal.title)
186}
187
188export const register: Register = (on, options) => {
189 const capture: Capture = options.capture === 'confirm' || options.capture === 'off' ? options.capture : 'auto'
190
191 // What this turn did, counted from the tool calls themselves. Not `kg turn take`: the
192 // kgai plugin's Stop hook reads and clears those marks before this mod's turn.complete.
193 let edited = 0
194 let recorded = false
195 let forkedTurnId: string | undefined
196
197 if (options.compact !== false) {
198 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
199 const row = e.props.isErrored || e.props.isInterrupted ? undefined : kgRow(e.props.tool, e.props.input, e.props.output)
200 if (row === undefined) {
201 compacted.delete(e.props.tool_use_id)
202 return next(e)
203 }
204 compact(e.props.tool_use_id)
205 const { Text } = $.ui.resolve(e)
206 return <Text dimColor>{e.props.isRunning ? row.running : row.done}</Text>
207 })
208
209 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
210 if (e.props.isErrored || !compacted.has(e.props.tool_use_id)) {
211 return next(e)
212 }
213 const { Box } = $.ui.resolve(e)
214 return <Box />
215 })
216 }
217
218 if (capture === 'off') {
219 return
220 }
221
222 on('turn.start', ($, e, next) => {
223 edited = 0
224 recorded = false
225 return next(e)
226 })
227
228 on('prompt.submit', async ($, e, next) => {
229 // The band belongs to the turn that proposed it.
230 if ((await read($, pending)) !== null) {
231 await update($, pending, () => null)
232 }
233 return next(e)
234 })
235
236 on('tool.call', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
237 const ran = await next(e)
238 if (ran.deny === undefined && ran.isError !== true) {
239 edited += 1
240 }
241 return ran
242 })
243
244 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
245 const ran = await next(e)
246 if (ran.deny === undefined && ran.isError !== true && INGEST_COMMAND.test(e.command) && !e.command.includes('--dry-run')) {
247 recorded = true
248 }
249 return ran
250 })
251
252 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
253 const ran = await next(e)
254 if (ran.deny === undefined && RECORD_SKILL.test(e.skill)) {
255 recorded = true
256 }
257 return ran
258 })
259
260 // Both kgai Stop commands still run beneath (auto-sync.sh spawns its sync); only the
261 // capture instruction is taken out of the folded result, so the turn is not continued.
262 on('classic.Stop', async ($, e, next) => {
263 const r = await next(e)
264 const isNudge = (text: string) => text.startsWith(CLASSIC_NUDGE_PREFIX)
265 const context = r.additionalContext?.filter(text => !isNudge(text))
266 const isBlockNudge = r.block !== undefined && isNudge(r.block)
267 if (context?.length === r.additionalContext?.length && !isBlockNudge) {
268 return r
269 }
270 debug($, 'classic capture nudge suppressed')
271 const { additionalContext: _context, block: _block, ...rest } = r
272 return {
273 ...rest,
274 ...(context && context.length > 0 ? { additionalContext: context } : {}),
275 ...(r.block !== undefined && !isBlockNudge ? { block: r.block } : {}),
276 }
277 })
278
279 on('turn.complete', async ($, e, next) => {
280 const r = await next(e)
281 const isDue = e.agentId === undefined && e.reason === 'answer' && edited > 0 && !recorded && forkedTurnId !== e.turnId
282 if (!isDue) {
283 return r
284 }
285 forkedTurnId = e.turnId
286 edited = 0
287 const run = () => check($, capture).catch((err: unknown) => fail($, err instanceof Error ? err.message : String(err)))
288 // A session nothing draws on (claude -p) ends with its turn and would drop a check left
289 // running, and nobody waits at a prompt there: check before the turn is over.
290 if ((await $.session.surfaces()).length === 0) {
291 await run()
292 return r
293 }
294 // Otherwise off the turn's own dispatch, so the turn ends now and the check runs beside it.
295 $.clock.after(0, () => {
296 void run()
297 })
298 return r
299 })
300
301 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
302 const proposed = await read($, pending)
303 if (proposed === null || e.props.hasSurvey) {
304 return next(e)
305 }
306
307 const { Box, Button, Text } = $.ui.resolve(e)
308 const record = async () => {
309 await update($, pending, () => null)
310 const error = await ingest($, proposed.payload)
311 return error === undefined ? announce($, proposed.title) : fail($, error)
312 }
313 const skip = () => update($, pending, () => null)
314
315 return (
316 <Box flexDirection="row" gap={1}>
317 <Text wrap="truncate-end">
318 kgai: record {proposed.title}?
319 </Text>
320 <Button key="record" label="Record" hotkey="r" variant="primary" onPress={record} />
321 <Button key="skip" label="Skip" hotkey="s" onPress={skip} />
322 </Box>
323 )
324 })
325}
326hooks/rows.ts 91 lines1// How kgai's own tool calls read in the transcript: one dim line each ("kgai: reading the
2// graph", "kgai: recorded ...") instead of the command, its JSON payload and the engine's
3// JSON answer. Only the drawing changes; the model and the stored transcript see it all.
4//
5// A call that errored keeps its full row, and so does anything that changes what kgai is
6// allowed to do (kg init / config, the kg-trust skill): those the person should read.
7
8// `kg <sub>` at the start of the command, by bare name or by path, after env assignments.
9const KG_COMMAND = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:\S*\/)?kg\s+([a-z][\w-]*)/
10const REAL_INGEST = /(?:^|[\s;&|(/])kg\s+ingest\b(?![^\n;&|]*--dry-run)/
11const READS = new Set(['context', 'history', 'as-of', 'search', 'resolve', 'query', 'conflicts', 'status'])
12
13// The kgai plugin's skills and commands, as the Skill tool names them.
14const CONSULT_SKILLS = new Set(['knowledge-graph', 'kg-ask', 'kg-history', 'kg-query', 'kg-conflicts', 'kg-review'])
15
16export type KgRow = { running: string; done: string }
17
18const stdoutOf = (output: unknown) =>
19 typeof output === 'object' && output !== null && typeof (output as { stdout?: unknown }).stdout === 'string'
20 ? (output as { stdout: string }).stdout
21 : ''
22
23// The titles the engine answered with, else the ones in the payload the command carried.
24export const titlesOf = (command: string, output: unknown): string[] => {
25 try {
26 const answer = JSON.parse(stdoutOf(output)) as { decisions?: { title?: unknown }[] }
27 const titles = (answer.decisions ?? []).map(d => d.title).filter((t): t is string => typeof t === 'string')
28 if (titles.length > 0) {
29 return titles
30 }
31 } catch {
32 // Not one JSON document (two commands, a log line): fall back to the payload.
33 }
34 return [...command.matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => (m[1] ?? '').replace(/\\(.)/g, '$1'))
35}
36
37const recorded = (titles: string[]) =>
38 titles.length === 1 ? `kgai: recorded "${titles[0]}"` : titles.length > 1 ? `kgai: recorded ${titles.length} decisions` : 'kgai: recorded the decision'
39
40// The compact line for a Bash call, or undefined when the row stays as the engine draws it.
41export const bashRow = (command: string, output: unknown): KgRow | undefined => {
42 const sub = KG_COMMAND.exec(command)?.[1]
43 if (sub === undefined) {
44 return undefined
45 }
46 if (sub === 'ingest' || REAL_INGEST.test(command)) {
47 return REAL_INGEST.test(command)
48 ? { running: 'kgai: recording the decision…', done: recorded(titlesOf(command, output)) }
49 : { running: 'kgai: checking the decision…', done: 'kgai: checked the decision' }
50 }
51 if (sub === 'sync') {
52 return { running: 'kgai: syncing the graph…', done: 'kgai: synced the graph' }
53 }
54 if (READS.has(sub)) {
55 return { running: 'kgai: reading the graph…', done: 'kgai: read the graph' }
56 }
57 return undefined
58}
59
60// The compact line for a Skill call of the kgai plugin, or undefined.
61export const skillRow = (skill: string): KgRow | undefined => {
62 const name = /^kgai:(.+)$/.exec(skill)?.[1]
63 if (name === 'kg-decision') {
64 return { running: 'kgai: recording a decision…', done: 'kgai: recording a decision' }
65 }
66 if (name === 'kg-sync') {
67 return { running: 'kgai: syncing the graph…', done: 'kgai: syncing the graph' }
68 }
69 if (name !== undefined && CONSULT_SKILLS.has(name)) {
70 return { running: 'kgai: consulting the graph…', done: 'kgai: consulting the graph' }
71 }
72 return undefined
73}
74
75// The compact line for any tool row, from what the ToolUse / ToolResult sites carry.
76export const kgRow = (tool: string, input: unknown, output: unknown): KgRow | undefined => {
77 const field = (key: string) =>
78 typeof input === 'object' && input !== null && typeof (input as Record<string, unknown>)[key] === 'string'
79 ? ((input as Record<string, unknown>)[key] as string)
80 : undefined
81 if (tool === 'Bash') {
82 const command = field('command')
83 return command === undefined ? undefined : bashRow(command, output)
84 }
85 if (tool === 'Skill') {
86 const skill = field('skill')
87 return skill === undefined ? undefined : skillRow(skill)
88 }
89 return undefined
90}
91types/index.d.ts 12 lines1// A decision the side check proposed in `confirm` mode, waiting for Record / Skip.
2export type KgaiModPending = { title: string; payload: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'kgai-mod': {
7 pending: KgaiModPending | null
8 hasToastedFailure: boolean
9 }
10 }
11}
12