SLOPSHOPPER

kgai-mod

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…

newbandrowsguardtoaststatus
★ 4v0.1.0MITupdated 2026-10-05kgaidev/kgai/mods/kgai-mod
A shopper browsing a rack in a slop shop
README

kgai — shared decision memory for AI dev teams

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.

  • Syncs like version control — without the merge conflicts. Every decision is an immutable, content-addressed event; teammates (or their AIs) recording in parallel can never produce a textual conflict. Only real semantic conflicts surface — as branches you resolve with one new decision, and the resolution is kept too.
  • It even remembers the dead ends. Rejected approaches stay in the graph with the reason they failed — so no engineer, and no AI, re-walks a path the team already proved wrong.
  • Measured, not promised. 1,000,000 decisions across 30 writers' shards: a decision lookup still answers in ~100 ms. Numbers at kgai.dev.

See it in action

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.

Quick start

# 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.

Also on Codex CLI and Gemini CLI

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.

Install the CLI by hand

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.

Initialize the graph for a project

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:

  1. Let Claude interview the codebase (and you). In a Claude Code session, ask something like: "Walk through this codebase, identify the main domain elements (features, services, business objects) and how they relate, ask me about anything that looks like a deliberate decision, and record the results into the knowledge graph." Claude maps the elements, asks you for the why behind non-obvious boundaries, and records everything via kg ingest.
  2. Import known past decisions by hand — old ADRs, wiki pages, tribal knowledge. Write them as one 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.

Importing past decisions

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": [ … ] } }

What you can do

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 datekg as-of 2026-01-01
Resolve conflicting decision branches/kgai:kg-conflicts
Raw query (power users)/kgai:kg-query · kg query "…"

Automatic capture — and no noise

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.

Experimental: kgai-mod (Claude Code Mods)

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:

captureWhat happens at the end of a turn that edited code
auto (default)records a structural decision silently and shows the status line
confirmshows the decision above the prompt with Record / Skip; the band goes away with the next prompt
offnothing, 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.

See it in your editor

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.

Under the hood

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.

Configuration

Settings resolve in three layers, most specific first — the same shape in every file, the way git config and npm do it:

LayerFileWho it is for
session<store>/kg.config.jsonthis install; never committed (holds the cloud token)
project<repo>/.kgairccommitted — the repo's default for everyone who clones it
global~/.kgai/config.jsonthis 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.

KeyWhat it isWhere it may be set
promptyour capture rules, given to the agentany layer
storewhere the decision log livesproject, global
remotesync targetsession, global
cloud_urlkgai cloud brokersession

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.

EnvMeaningDefault
KGAI_STOREknowledge-graph store location (beats the store setting)<project>/.kgai/store (per-project)
KGAI_PROJECTproject root used to locate the storegit top-level (worktrees → main worktree)
KGAI_HOMEengine binary + native lib home~/.kgai
KGAI_ACTORyour name on recorded decisionsgit user / $USER
KG_RELEASE_BASEprebuilt download basethis 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.

Team sync

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

Source 3 files
hooks/register.tsx 326 lines
1// 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}
326
hooks/rows.ts 91 lines
1// 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}
91
types/index.d.ts 12 lines
1// 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