@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.
Files changed (3) hide show
  1. package/dist/cli.js +16 -0
  2. package/dist/kernel.js +142 -15
  3. 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 && risks.indexOf("session bookkeeping, not durable knowledge") === -1,
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: input.pendingReview ? "pending" : "approved",
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, input.pendingReview ? "pending" : "packets");
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 = /\b[a-z][a-z0-9]*[A-Z]\w*\b/.test(prose) // camelCase
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.4",
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": [