@kage-core/kage-graph-mcp 2.5.4 → 2.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +16 -0
- package/dist/kernel.js +142 -15
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -708,6 +708,22 @@ async function main() {
|
|
|
708
708
|
}
|
|
709
709
|
return;
|
|
710
710
|
}
|
|
711
|
+
if (command === "gen-plugin-hooks") {
|
|
712
|
+
// Maintainer tool: regenerate plugin/hooks/* from the setupAgent("claude-code") templates
|
|
713
|
+
// so the plugin and the npm install path ship identical hooks (one source of truth).
|
|
714
|
+
const pluginDir = takeArg(args, "--plugin-dir") ?? (0, node_path_1.join)(process.cwd(), "plugin");
|
|
715
|
+
const result = (0, kernel_js_1.generatePluginHooks)(pluginDir);
|
|
716
|
+
if (args.includes("--json")) {
|
|
717
|
+
console.log(JSON.stringify({ plugin_dir: pluginDir, ...result }, null, 2));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
console.log(`Generated plugin hooks in ${(0, node_path_1.join)(pluginDir, "hooks")}`);
|
|
721
|
+
console.log(` scripts: ${result.scripts.join(", ")}`);
|
|
722
|
+
console.log(` events: ${result.events.join(", ")}`);
|
|
723
|
+
if (result.removed.length)
|
|
724
|
+
console.log(` removed: ${result.removed.join(", ")}`);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
711
727
|
if (command === "daemon") {
|
|
712
728
|
const action = args[1];
|
|
713
729
|
const projectDir = projectArg(args);
|
package/dist/kernel.js
CHANGED
|
@@ -102,6 +102,7 @@ exports.compactProject = compactProject;
|
|
|
102
102
|
exports.installAgentPolicy = installAgentPolicy;
|
|
103
103
|
exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
|
|
104
104
|
exports.buildEmbeddingIndex = buildEmbeddingIndex;
|
|
105
|
+
exports.isUngroundedConversationalCapture = isUngroundedConversationalCapture;
|
|
105
106
|
exports.recall = recall;
|
|
106
107
|
exports.recallWithEmbeddings = recallWithEmbeddings;
|
|
107
108
|
exports.queryCodeGraph = queryCodeGraph;
|
|
@@ -150,6 +151,7 @@ exports.capture = capture;
|
|
|
150
151
|
exports.createPublicCandidate = createPublicCandidate;
|
|
151
152
|
exports.registryRecommendations = registryRecommendations;
|
|
152
153
|
exports.setupAgent = setupAgent;
|
|
154
|
+
exports.generatePluginHooks = generatePluginHooks;
|
|
153
155
|
exports.setupDoctor = setupDoctor;
|
|
154
156
|
exports.verifyAgentActivation = verifyAgentActivation;
|
|
155
157
|
exports.observe = observe;
|
|
@@ -2133,9 +2135,18 @@ function evaluateMemoryAdmission(projectDir, packet) {
|
|
|
2133
2135
|
score -= 10;
|
|
2134
2136
|
risks.push("too little context");
|
|
2135
2137
|
}
|
|
2138
|
+
// Ungrounded conversational chatter (a path-less, repo-reference-free user outburst) is not
|
|
2139
|
+
// durable memory regardless of its other signals — keywords like "issue"/"before" can
|
|
2140
|
+
// otherwise score it as a candidate. Hard-block admission so it can never auto-promote.
|
|
2141
|
+
if (isUngroundedConversationalCapture(packet)) {
|
|
2142
|
+
score -= 45;
|
|
2143
|
+
risks.push("ungrounded conversational utterance, not durable knowledge");
|
|
2144
|
+
}
|
|
2136
2145
|
const bounded = Math.max(0, Math.min(100, score));
|
|
2137
2146
|
return {
|
|
2138
|
-
admit: bounded >= 45
|
|
2147
|
+
admit: bounded >= 45
|
|
2148
|
+
&& risks.indexOf("session bookkeeping, not durable knowledge") === -1
|
|
2149
|
+
&& risks.indexOf("ungrounded conversational utterance, not durable knowledge") === -1,
|
|
2139
2150
|
class: bounded >= 72 ? "high_signal" : bounded >= 45 ? "candidate" : "episodic_only",
|
|
2140
2151
|
score: bounded,
|
|
2141
2152
|
reasons,
|
|
@@ -7970,6 +7981,62 @@ function isSerializedDumpBody(body) {
|
|
|
7970
7981
|
return isSerializedDumpTitle(t)
|
|
7971
7982
|
|| /<task-notification\b|<tool-use-id\b|"hookSpecificOutput"|"isImage"\s*:|"noOutputExpected"\s*:|"interrupted"\s*:\s*(true|false)/i.test(t.slice(0, 4000));
|
|
7972
7983
|
}
|
|
7984
|
+
// Repo grounding signals — does the text point at something concrete in the codebase? These
|
|
7985
|
+
// three predicates are the shared vocabulary for "grounded": observationSignalScore weights
|
|
7986
|
+
// them into its durable-knowledge score, and the ungrounded-utterance guard uses their union
|
|
7987
|
+
// to decide whether a path-less capture references the repo at all. Kept as one source of
|
|
7988
|
+
// truth so the scorer and the guard can never drift on what "grounded" means.
|
|
7989
|
+
function textCitesPath(text) {
|
|
7990
|
+
return /\b[\w.-]+\/[\w./-]+\b/.test(text)
|
|
7991
|
+
|| /\b[\w-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|json|ya?ml|toml|md|sh|css|html|sql)\b/i.test(text);
|
|
7992
|
+
}
|
|
7993
|
+
function textHasCodeIdentifier(text) {
|
|
7994
|
+
return /\b[a-z][a-z0-9]*[A-Z]\w*\b/.test(text) // camelCase
|
|
7995
|
+
|| /\b[a-z0-9]+_[a-z0-9_]+\b/.test(text) // snake_case
|
|
7996
|
+
|| /`[^`]+`/.test(text)
|
|
7997
|
+
|| /\b\w+\(\)/.test(text);
|
|
7998
|
+
}
|
|
7999
|
+
function textHasCommand(text) {
|
|
8000
|
+
return /(^|\s)(npm|pnpm|yarn|npx|node|git|cargo|make|pytest|go|tsc|kage)\s+[\w.-]/.test(text);
|
|
8001
|
+
}
|
|
8002
|
+
function hasRepoGroundingSignal(text) {
|
|
8003
|
+
const t = text ?? "";
|
|
8004
|
+
if (!t.trim())
|
|
8005
|
+
return false;
|
|
8006
|
+
return textCitesPath(t) || textHasCodeIdentifier(t) || textHasCommand(t);
|
|
8007
|
+
}
|
|
8008
|
+
// A raw conversational user utterance: a frustrated/rhetorical/imperative chat message aimed
|
|
8009
|
+
// at the agent — e.g. "why are you asking me???!!! it's your job, don't stop before you...".
|
|
8010
|
+
// It is plain prose, so the serialized-dump guard waves it through, yet it is chatter, not
|
|
8011
|
+
// durable repo knowledge. Detection is deliberately narrow — a run of emphatic terminal
|
|
8012
|
+
// punctuation, or one of a curated set of rhetorical / second-person-at-the-assistant
|
|
8013
|
+
// phrases — so it stays clear of normal declarative learnings (which read as statements, not
|
|
8014
|
+
// outbursts addressed to "you").
|
|
8015
|
+
function looksLikeRawUserUtterance(text) {
|
|
8016
|
+
const t = (text ?? "").trim();
|
|
8017
|
+
if (!t)
|
|
8018
|
+
return false;
|
|
8019
|
+
// Emphatic or mixed terminal punctuation ("???", "!!", "?!") — common in venting chat,
|
|
8020
|
+
// essentially absent from curated memory prose.
|
|
8021
|
+
if (/[!?]{2,}/.test(t))
|
|
8022
|
+
return true;
|
|
8023
|
+
// Rhetorical questions / second-person-imperative frustration directed at the assistant.
|
|
8024
|
+
return /\b(why are you|why did you|why would you|why aren'?t you|are you (kidding|serious|even|really)|it'?s your job|that'?s your job|do your job|don'?t stop|stop asking|stop before you|keep going|hurry up|just do it|figure it out yourself|i (already )?told you)\b/i.test(t);
|
|
8025
|
+
}
|
|
8026
|
+
// Capture-noise guard for ungrounded chat. A packet trips it only when ALL hold: it cites zero
|
|
8027
|
+
// repo paths, its text carries no repo grounding signal (no path/file/identifier/command), and
|
|
8028
|
+
// it reads as a raw conversational user utterance. The conjunction is what keeps it safe — a
|
|
8029
|
+
// real ungrounded decision or convention names a symbol, file, command, or rule and so is never
|
|
8030
|
+
// caught. Such packets are routed to pending (not auto-approved) at capture time and withheld
|
|
8031
|
+
// from recall, mirroring how serialized dumps are gated.
|
|
8032
|
+
function isUngroundedConversationalCapture(packet) {
|
|
8033
|
+
if (packet.paths && packet.paths.length > 0)
|
|
8034
|
+
return false;
|
|
8035
|
+
const text = `${packet.title ?? ""}\n${packet.body ?? ""}`;
|
|
8036
|
+
if (hasRepoGroundingSignal(text))
|
|
8037
|
+
return false;
|
|
8038
|
+
return looksLikeRawUserUtterance(text);
|
|
8039
|
+
}
|
|
7973
8040
|
// Collapse whitespace and hard-cap a value rendered inline in a context block, so one
|
|
7974
8041
|
// oversized field (e.g. a graph fact whose body is a raw transcript) can never blow up
|
|
7975
8042
|
// the assembled output — the 270k-char overflow that motivated this guard.
|
|
@@ -8055,7 +8122,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8055
8122
|
];
|
|
8056
8123
|
return { packet, score: score_breakdown.final, relevance, why_matched: unique(why).slice(0, 12), score_breakdown };
|
|
8057
8124
|
})
|
|
8058
|
-
.filter((entry) => entry.relevance > 0 && !isSerializedDumpTitle(entry.packet.title))
|
|
8125
|
+
.filter((entry) => entry.relevance > 0 && !isSerializedDumpTitle(entry.packet.title) && !isUngroundedConversationalCapture(entry.packet))
|
|
8059
8126
|
.sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title));
|
|
8060
8127
|
const scored = diversifyRecallEntries(rankedScored, limit)
|
|
8061
8128
|
.map(({ relevance, ...entry }) => entry);
|
|
@@ -8067,7 +8134,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8067
8134
|
const { score, why } = pendingLexicalScores.get(packet.id) ?? { score: 0, why: [] };
|
|
8068
8135
|
return { packet, score, why_matched: why };
|
|
8069
8136
|
})
|
|
8070
|
-
.filter((entry) => entry.score > 0)
|
|
8137
|
+
.filter((entry) => entry.score > 0 && !isUngroundedConversationalCapture(entry.packet))
|
|
8071
8138
|
.sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title))
|
|
8072
8139
|
.filter((entry) => {
|
|
8073
8140
|
const key = `${entry.packet.type}:${entry.packet.title.toLowerCase()}:${entry.packet.paths.join(",")}`;
|
|
@@ -13711,6 +13778,20 @@ function capture(input) {
|
|
|
13711
13778
|
if (missingPaths.length) {
|
|
13712
13779
|
warnings.push(`Some referenced paths do not exist in this repo: ${missingPaths.join(", ")}`);
|
|
13713
13780
|
}
|
|
13781
|
+
// Ungrounded conversational chatter — a frustrated/rhetorical user message with no cited repo
|
|
13782
|
+
// paths — is not durable memory. Don't hard-reject (a reviewer may still salvage it); instead
|
|
13783
|
+
// deny it the auto-approve fast path so it lands in the pending inbox, tagged with why. Recall
|
|
13784
|
+
// withholds it regardless of status. Explicit pendingReview captures already route to review.
|
|
13785
|
+
const ungroundedUtterance = isUngroundedConversationalCapture({
|
|
13786
|
+
title: input.title,
|
|
13787
|
+
body: input.body,
|
|
13788
|
+
paths: groundedPaths,
|
|
13789
|
+
});
|
|
13790
|
+
const routeToPending = Boolean(input.pendingReview) || ungroundedUtterance;
|
|
13791
|
+
if (ungroundedUtterance && !input.pendingReview) {
|
|
13792
|
+
warnings.push("Routed to pending review: this reads as an ungrounded conversational message with no cited repo paths, " +
|
|
13793
|
+
"not a durable learning. Cite a repo path or restate it as a reusable insight to approve it.");
|
|
13794
|
+
}
|
|
13714
13795
|
const createdAt = nowIso();
|
|
13715
13796
|
// Agent-asserted links to code-graph nodes (PRD `graph_nodes`): the agent recording the
|
|
13716
13797
|
// memory knows which symbol/route/file the rule is about, so let it declare them instead
|
|
@@ -13729,9 +13810,9 @@ function capture(input) {
|
|
|
13729
13810
|
scope: "repo",
|
|
13730
13811
|
visibility: "team",
|
|
13731
13812
|
sensitivity: "internal",
|
|
13732
|
-
status:
|
|
13813
|
+
status: routeToPending ? "pending" : "approved",
|
|
13733
13814
|
confidence: DEFAULT_CONFIDENCE,
|
|
13734
|
-
tags: input.tags ?? [],
|
|
13815
|
+
tags: ungroundedUtterance ? unique([...(input.tags ?? []), "needs-grounding"]) : (input.tags ?? []),
|
|
13735
13816
|
paths: groundedPaths,
|
|
13736
13817
|
stack: input.stack ?? [],
|
|
13737
13818
|
source_refs: [
|
|
@@ -13793,7 +13874,7 @@ function capture(input) {
|
|
|
13793
13874
|
...evaluateMemoryQuality(input.projectDir, packet),
|
|
13794
13875
|
...(contradictions.length ? { contradicts: contradictions.map((c) => c.packet_id) } : {}),
|
|
13795
13876
|
};
|
|
13796
|
-
const path = writePacket(input.projectDir, packet,
|
|
13877
|
+
const path = writePacket(input.projectDir, packet, routeToPending ? "pending" : "packets");
|
|
13797
13878
|
recordMemoryAudit(input.projectDir, "capture", [packet], {
|
|
13798
13879
|
type: packet.type,
|
|
13799
13880
|
status: packet.status,
|
|
@@ -14354,6 +14435,58 @@ exit 0
|
|
|
14354
14435
|
setSnippet(paths[agent] || null, universal, [`Merge this MCP stdio config into ${agent}'s MCP settings.`, "Restart the agent after updating config."]);
|
|
14355
14436
|
return result;
|
|
14356
14437
|
}
|
|
14438
|
+
// Single source of truth for Claude Code hooks. The plugin's hook scripts and hooks.json are
|
|
14439
|
+
// GENERATED from the exact same setupAgent("claude-code") output the npm install path writes —
|
|
14440
|
+
// so a hook fix lands in both channels and they can't silently drift. The hook scripts call the
|
|
14441
|
+
// kage CLI and never reference their own location, so the same bodies serve both the ~/.claude
|
|
14442
|
+
// install and the plugin; only the wiring's command path is re-targeted to ${CLAUDE_PLUGIN_ROOT}.
|
|
14443
|
+
// Run via `kage gen-plugin-hooks`; a unit test asserts the committed plugin/hooks match this.
|
|
14444
|
+
function generatePluginHooks(pluginDir) {
|
|
14445
|
+
const tmpHome = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-home-"));
|
|
14446
|
+
const tmpProject = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-proj-"));
|
|
14447
|
+
try {
|
|
14448
|
+
setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome });
|
|
14449
|
+
const srcHookDir = (0, node_path_1.join)(tmpHome, ".claude", "kage", "hooks");
|
|
14450
|
+
const settings = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(tmpHome, ".claude", "settings.json"), "utf8"));
|
|
14451
|
+
const hooksOut = (0, node_path_1.join)(pluginDir, "hooks");
|
|
14452
|
+
(0, node_fs_1.mkdirSync)(hooksOut, { recursive: true });
|
|
14453
|
+
// Copy the generated hook scripts verbatim (path-agnostic — they shell out to `kage`).
|
|
14454
|
+
const scripts = (0, node_fs_1.readdirSync)(srcHookDir).filter((f) => f.endsWith(".sh")).sort();
|
|
14455
|
+
for (const file of scripts) {
|
|
14456
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hooksOut, file), (0, node_fs_1.readFileSync)((0, node_path_1.join)(srcHookDir, file), "utf8"), { mode: 0o755 });
|
|
14457
|
+
}
|
|
14458
|
+
// Re-target the event wiring from the install path's `~/.claude/kage/hooks/` to the plugin's
|
|
14459
|
+
// `${CLAUDE_PLUGIN_ROOT}/hooks/`, preserving events, matchers, and timeouts exactly.
|
|
14460
|
+
const events = Object.keys(settings.hooks ?? {});
|
|
14461
|
+
const pluginHooks = { hooks: {} };
|
|
14462
|
+
for (const event of events) {
|
|
14463
|
+
pluginHooks.hooks[event] = (settings.hooks ?? {})[event].map((entry) => ({
|
|
14464
|
+
...(entry.matcher !== undefined ? { matcher: entry.matcher } : {}),
|
|
14465
|
+
hooks: entry.hooks.map((h) => {
|
|
14466
|
+
const m = h.command.match(/^bash ~\/\.claude\/kage\/hooks\/(.+)$/);
|
|
14467
|
+
return {
|
|
14468
|
+
type: h.type,
|
|
14469
|
+
command: m ? `bash "\${CLAUDE_PLUGIN_ROOT}/hooks/${m[1]}"` : h.command,
|
|
14470
|
+
...(h.timeout !== undefined ? { timeout: h.timeout } : {}),
|
|
14471
|
+
};
|
|
14472
|
+
}),
|
|
14473
|
+
}));
|
|
14474
|
+
}
|
|
14475
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hooksOut, "hooks.json"), `${JSON.stringify(pluginHooks, null, 2)}\n`, "utf8");
|
|
14476
|
+
// Drop committed scripts the install path no longer emits (e.g. kage-prompt-context.sh,
|
|
14477
|
+
// now subsumed by observe.sh handling UserPromptSubmit).
|
|
14478
|
+
const removed = [];
|
|
14479
|
+
for (const stale of (0, node_fs_1.readdirSync)(hooksOut).filter((f) => f.endsWith(".sh") && !scripts.includes(f))) {
|
|
14480
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.join)(hooksOut, stale));
|
|
14481
|
+
removed.push(stale);
|
|
14482
|
+
}
|
|
14483
|
+
return { scripts, removed, events };
|
|
14484
|
+
}
|
|
14485
|
+
finally {
|
|
14486
|
+
(0, node_fs_1.rmSync)(tmpHome, { recursive: true, force: true });
|
|
14487
|
+
(0, node_fs_1.rmSync)(tmpProject, { recursive: true, force: true });
|
|
14488
|
+
}
|
|
14489
|
+
}
|
|
14357
14490
|
function upsertJsonMcpServer(path, name, server) {
|
|
14358
14491
|
ensureDir((0, node_path_1.dirname)(path));
|
|
14359
14492
|
let config = {};
|
|
@@ -14756,19 +14889,13 @@ function observationSignalScore(observation) {
|
|
|
14756
14889
|
const causalHits = SIGNAL_CAUSAL_MARKERS.filter((marker) => lower.includes(marker)).length;
|
|
14757
14890
|
if (causalHits > 0)
|
|
14758
14891
|
score += Math.min(0.35, 0.25 + (causalHits - 1) * 0.05);
|
|
14759
|
-
const citesPath = Boolean(observation.path)
|
|
14760
|
-
|| /\b[\w.-]+\/[\w./-]+\b/.test(prose)
|
|
14761
|
-
|| /\b[\w-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|json|ya?ml|toml|md|sh|css|html|sql)\b/i.test(prose);
|
|
14892
|
+
const citesPath = Boolean(observation.path) || textCitesPath(prose);
|
|
14762
14893
|
if (citesPath)
|
|
14763
14894
|
score += 0.2;
|
|
14764
|
-
const codeIdentifier =
|
|
14765
|
-
|| /\b[a-z0-9]+_[a-z0-9_]+\b/.test(prose) // snake_case
|
|
14766
|
-
|| /`[^`]+`/.test(prose)
|
|
14767
|
-
|| /\b\w+\(\)/.test(prose);
|
|
14895
|
+
const codeIdentifier = textHasCodeIdentifier(prose);
|
|
14768
14896
|
if (codeIdentifier)
|
|
14769
14897
|
score += 0.15;
|
|
14770
|
-
const commandLine = Boolean(observation.command)
|
|
14771
|
-
|| /(^|\s)(npm|pnpm|yarn|npx|node|git|cargo|make|pytest|go|tsc|kage)\s+[\w.-]/.test(prose);
|
|
14898
|
+
const commandLine = Boolean(observation.command) || textHasCommand(prose);
|
|
14772
14899
|
if (commandLine)
|
|
14773
14900
|
score += 0.15;
|
|
14774
14901
|
const words = combined.split(/\s+/).filter(Boolean).length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kage-core/kage-graph-mcp",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.5",
|
|
4
4
|
"description": "Team memory for coding agents: captures the decisions, runbooks, and bug fixes that get lost, verified against your code and shared via git. MCP server, zero deps, no account.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|