@kage-core/kage-graph-mcp 3.0.0 → 3.2.0

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 CHANGED
@@ -489,7 +489,17 @@ async function main() {
489
489
  console.log(` … and ${packets.length - 20} more`);
490
490
  return;
491
491
  }
492
+ if (sub === "view") {
493
+ (0, okf_js_1.migratePacketsToOkf)(project, { includePending: args.includes("--pending") });
494
+ const concepts = (0, okf_js_1.loadOkfConcepts)((0, okf_js_1.okfBundleDir)(project), { projectDir: project });
495
+ const out = (0, node_path_1.join)((0, okf_js_1.okfBundleDir)(project), "viewer.html");
496
+ (0, node_fs_1.writeFileSync)(out, (0, okf_js_1.okfViewerHtml)(concepts, { title: (0, node_path_1.basename)(project) }), "utf8");
497
+ console.log(`OKF viewer: ${concepts.length} concept(s) — a self-contained page, no server, no giant URL.`);
498
+ console.log(`Open it: open ${out}`);
499
+ return;
500
+ }
492
501
  console.log("kage okf — Open Knowledge Format is Kage's standard memory format.");
502
+ console.log(" kage okf view [--project <dir>] view your memory as a clean OKF bundle (self-contained page)");
493
503
  console.log(" kage okf migrate [--project <dir>] [--pending] packets → OKF bundle (.agent_memory/okf)");
494
504
  console.log(" kage okf lint [<dir|file>] [--project <dir>] check OKF conformance");
495
505
  console.log(" kage okf import [<dir>] [--project <dir>] [--json] read an OKF bundle back into packets");
package/dist/daemon.js CHANGED
@@ -823,7 +823,7 @@ async function startViewer(projectDir, options = {}) {
823
823
  res.end((0, node_fs_1.readFileSync)(filePath));
824
824
  });
825
825
  await new Promise((resolveListen) => server.listen(port, host, resolveListen));
826
- console.log(`Kage viewer listening on ${url}`);
826
+ console.log(`Kage viewer http://${host}:${port}/`);
827
827
  process.on("SIGTERM", () => {
828
828
  liveFeed.close();
829
829
  server.close(() => process.exit(0));
package/dist/kernel.js CHANGED
@@ -154,6 +154,7 @@ exports.setupAgent = setupAgent;
154
154
  exports.generatePluginHooks = generatePluginHooks;
155
155
  exports.setupDoctor = setupDoctor;
156
156
  exports.verifyAgentActivation = verifyAgentActivation;
157
+ exports.pruneObservations = pruneObservations;
157
158
  exports.observe = observe;
158
159
  exports.observationSignalScore = observationSignalScore;
159
160
  exports.kageSessionCaptureReport = kageSessionCaptureReport;
@@ -209,6 +210,7 @@ const node_worker_threads_1 = require("node:worker_threads");
209
210
  const ts = __importStar(require("typescript"));
210
211
  const index_js_1 = require("./registry/index.js");
211
212
  const okf_js_1 = require("./okf.js");
213
+ const metrics_math_js_1 = require("./metrics-math.js");
212
214
  exports.PACKET_SCHEMA_VERSION = 2;
213
215
  exports.MEMORY_TYPES = [
214
216
  "repo_map",
@@ -375,25 +377,32 @@ For normal coding tasks:
375
377
  For quick factual questions, \`kage_context\` alone is enough. For status or demo requests, call \`kage_metrics\`.
376
378
  ${AGENTS_POLICY_END}
377
379
  `;
380
+ // Hooks pass raw user prompts as recall queries ("what is X? can we replace it"),
381
+ // so interrogatives, pronouns, and auxiliaries must be stopwords too — otherwise
382
+ // filler words collect BM25/vector/graph credit and drown the query's rare,
383
+ // high-IDF terms (a packet literally titled with the queried term lost to
384
+ // packets matching only "what"/"can"/"we").
378
385
  const STOPWORDS = new Set([
379
- "a",
380
- "an",
381
- "and",
382
- "are",
383
- "do",
384
- "does",
385
- "for",
386
- "how",
387
- "i",
388
- "in",
389
- "is",
390
- "it",
391
- "of",
392
- "on",
393
- "or",
394
- "the",
395
- "to",
396
- "with",
386
+ "a", "about", "again", "also", "an", "and", "are", "as", "at",
387
+ "be", "been", "being", "but", "by",
388
+ "can", "could",
389
+ "did", "do", "does",
390
+ "else",
391
+ "for", "from",
392
+ "had", "has", "have", "having", "he", "her", "hers", "here", "him", "his", "how",
393
+ "i", "if", "in", "into", "is", "it", "its",
394
+ "just",
395
+ "let", "lets",
396
+ "may", "me", "might", "mine", "my",
397
+ "no", "not",
398
+ "of", "on", "once", "or", "our", "ours", "over",
399
+ "please",
400
+ "shall", "she", "should", "so",
401
+ "than", "that", "the", "their", "theirs", "them", "then", "there", "these", "they", "this", "those", "to", "too",
402
+ "under", "us",
403
+ "very",
404
+ "was", "we", "were", "what", "when", "where", "which", "who", "whom", "whose", "why", "will", "would", "with",
405
+ "you", "your", "yours",
397
406
  ]);
398
407
  function memoryRoot(projectDir) {
399
408
  return (0, node_path_1.join)(projectDir, ".agent_memory");
@@ -7010,6 +7019,7 @@ function refreshProject(projectDir, options = {}) {
7010
7019
  }
7011
7020
  const validation = validateProject(projectDir);
7012
7021
  const metrics = kageMetricsShallow(projectDir, { codeGraph, knowledgeGraph, validation });
7022
+ pruneObservations(projectDir);
7013
7023
  ensureDir(reportsDir(projectDir));
7014
7024
  writeJson((0, node_path_1.join)(reportsDir(projectDir), "context-slots.json"), kageContextSlots(projectDir));
7015
7025
  writeJson((0, node_path_1.join)(reportsDir(projectDir), "handoff.json"), kageMemoryHandoff(projectDir));
@@ -7943,12 +7953,17 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
7943
7953
  const vector = Number(vectorScore.toFixed(2));
7944
7954
  const usage = Number(usageScore.toFixed(2));
7945
7955
  const pathTypeTagWeight = packet.type === "reference" ? 0.2 : 0.8;
7946
- // Popularity (usage) must only AMPLIFY genuine relevance, never float a packet that has no
7947
- // lexical/semantic/graph/intent match to the top — that is what produced confident
7948
- // off-domain junk (a hot packet ranked #1 for a query it shared no terms with).
7956
+ // Priors (usage, quality, freshness) must only AMPLIFY genuine relevance, never float a
7957
+ // packet that has no lexical/semantic/graph/tag/intent match to the top — that is what
7958
+ // produced confident off-domain junk: a hot, high-quality packet ranked above the one
7959
+ // packet whose title literally contained the queried term, because its unconditional
7960
+ // quality+freshness (~12 pts) beat a weak-but-real lexical match.
7949
7961
  const coreRelevance = textScore + graphScore + intent + vector;
7962
+ const matchSignal = coreRelevance + pathTypeTag;
7950
7963
  const effectiveUsage = coreRelevance > 0 ? usage : 0;
7951
- const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + effectiveUsage + freshness + quality + feedback).toFixed(2));
7964
+ const effectiveQuality = matchSignal > 0 ? quality : 0;
7965
+ const effectiveFreshness = matchSignal > 0 ? freshness : Math.min(freshness, 0);
7966
+ const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + effectiveUsage + effectiveFreshness + effectiveQuality + feedback).toFixed(2));
7952
7967
  return {
7953
7968
  bm25: textScore,
7954
7969
  text: textScore,
@@ -12987,16 +13002,16 @@ function benchmarkCodingMemoryQuality(options = {}) {
12987
13002
  retrieved,
12988
13003
  latency_ms: latencyMs,
12989
13004
  context_tokens: estimateTokens(recalled.context_block),
12990
- recall: Object.fromEntries(metricsK.map((k) => [`at_${k}`, roundDecimal(codingRecallAt(retrieved, relevant, k) * 100, 2)])),
12991
- precision_at_5_percent: roundDecimal(codingPrecisionAt(retrieved, relevant, 5) * 100, 2),
12992
- ndcg_at_10: roundDecimal(codingNdcgAt(retrieved, relevant, 10), 4),
12993
- mrr: roundDecimal(codingMrr(retrieved, relevant), 4),
13005
+ recall: Object.fromEntries(metricsK.map((k) => [`at_${k}`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingRecallAt)(retrieved, relevant, k) * 100, 2)])),
13006
+ precision_at_5_percent: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingPrecisionAt)(retrieved, relevant, 5) * 100, 2),
13007
+ ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingNdcgAt)(retrieved, relevant, 10), 4),
13008
+ mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingMrr)(retrieved, relevant), 4),
12994
13009
  };
12995
13010
  });
12996
13011
  const sourceDiversity = codingMemorySourceDiversityProbe((0, node_path_1.join)(runDir, "source-diversity"));
12997
13012
  const allMemoryTokens = estimateTokens(loadApprovedPackets(projectDir).map(packetText).join("\n\n"));
12998
- const averageContextTokens = Math.round(averageNumber(perQuery.map((item) => item.context_tokens)));
12999
- const recallByK = Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, roundDecimal(averageNumber(perQuery.map((item) => item.recall[`at_${k}`] ?? 0)), 2)]));
13013
+ const averageContextTokens = Math.round((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.context_tokens)));
13014
+ const recallByK = Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.recall[`at_${k}`] ?? 0)), 2)]));
13000
13015
  const summary = {
13001
13016
  benchmark: "Kage coding memory quality",
13002
13017
  retrieval_mode: "kage-recall-default",
@@ -13006,14 +13021,14 @@ function benchmarkCodingMemoryQuality(options = {}) {
13006
13021
  refresh_ms: refreshMs,
13007
13022
  ...recallByK,
13008
13023
  recall_at_k_percent: Number(recallByK[`recall_at_${topK}_percent`] ?? 0),
13009
- precision_at_5_percent: roundDecimal(averageNumber(perQuery.map((item) => item.precision_at_5_percent)), 2),
13010
- ndcg_at_10: roundDecimal(averageNumber(perQuery.map((item) => item.ndcg_at_10)), 4),
13011
- mrr: roundDecimal(averageNumber(perQuery.map((item) => item.mrr)), 4),
13012
- median_latency_ms: percentileNumber(perQuery.map((item) => item.latency_ms), 0.5),
13013
- p95_latency_ms: percentileNumber(perQuery.map((item) => item.latency_ms), 0.95),
13024
+ precision_at_5_percent: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.precision_at_5_percent)), 2),
13025
+ ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.ndcg_at_10)), 4),
13026
+ mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.mrr)), 4),
13027
+ median_latency_ms: (0, metrics_math_js_1.percentileNumber)(perQuery.map((item) => item.latency_ms), 0.5),
13028
+ p95_latency_ms: (0, metrics_math_js_1.percentileNumber)(perQuery.map((item) => item.latency_ms), 0.95),
13014
13029
  all_memory_tokens: allMemoryTokens,
13015
13030
  average_context_tokens: averageContextTokens,
13016
- context_reduction_percent: roundDecimal(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13031
+ context_reduction_percent: (0, metrics_math_js_1.roundDecimal)(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13017
13032
  source_diversity_pass: sourceDiversity.pass,
13018
13033
  source_diversity_unique_sources: sourceDiversity.unique_sources,
13019
13034
  source_diversity_max_results_from_one_source: sourceDiversity.max_results_from_one_source,
@@ -13027,7 +13042,7 @@ function benchmarkCodingMemoryQuality(options = {}) {
13027
13042
  queries: queries.length,
13028
13043
  packets_per_topic: packetsPerTopic,
13029
13044
  distractors_per_topic: distractorsPerTopic,
13030
- categories: countByKey(queries, (item) => item.category),
13045
+ categories: (0, metrics_math_js_1.countByKey)(queries, (item) => item.category),
13031
13046
  },
13032
13047
  top_k: topK,
13033
13048
  metrics_k: metricsK,
@@ -13120,7 +13135,7 @@ function codingMemorySourceDiversityProbe(projectDir) {
13120
13135
  source,
13121
13136
  };
13122
13137
  });
13123
- const sourceCounts = countByKey(retrieved, (item) => item.source);
13138
+ const sourceCounts = (0, metrics_math_js_1.countByKey)(retrieved, (item) => item.source);
13124
13139
  const maxResultsFromOneSource = Math.max(0, ...Object.values(sourceCounts));
13125
13140
  const independentRank = retrieved.find((item) => item.source === "session:independent-session")?.rank ?? null;
13126
13141
  return {
@@ -13178,16 +13193,16 @@ function benchmarkMemoryScale(options = {}) {
13178
13193
  };
13179
13194
  });
13180
13195
  const allMemoryTokens = estimateTokens(loadApprovedPackets(projectDir).map(packetText).join("\n\n"));
13181
- const averageContextTokens = Math.round(averageNumber(queries.map((item) => item.context_tokens)));
13196
+ const averageContextTokens = Math.round((0, metrics_math_js_1.averageNumber)(queries.map((item) => item.context_tokens)));
13182
13197
  results.push({
13183
13198
  packets: size,
13184
13199
  refresh_ms: refreshMs,
13185
- recall_hit_rate_percent: roundDecimal((queries.filter((item) => item.hit).length / queries.length) * 100, 2),
13186
- median_recall_latency_ms: percentileNumber(queries.map((item) => item.latency_ms), 0.5),
13187
- p95_recall_latency_ms: percentileNumber(queries.map((item) => item.latency_ms), 0.95),
13200
+ recall_hit_rate_percent: (0, metrics_math_js_1.roundDecimal)((queries.filter((item) => item.hit).length / queries.length) * 100, 2),
13201
+ median_recall_latency_ms: (0, metrics_math_js_1.percentileNumber)(queries.map((item) => item.latency_ms), 0.5),
13202
+ p95_recall_latency_ms: (0, metrics_math_js_1.percentileNumber)(queries.map((item) => item.latency_ms), 0.95),
13188
13203
  all_memory_tokens: allMemoryTokens,
13189
13204
  average_context_tokens: averageContextTokens,
13190
- context_reduction_percent: roundDecimal(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13205
+ context_reduction_percent: (0, metrics_math_js_1.roundDecimal)(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13191
13206
  queries,
13192
13207
  });
13193
13208
  }
@@ -13319,7 +13334,7 @@ function codingObservation(index, item, target, variant) {
13319
13334
  topic: item.id,
13320
13335
  target,
13321
13336
  category: item.category,
13322
- title: `${titleCase(item.query)} repo memory ${variant + 1}`,
13337
+ title: `${(0, metrics_math_js_1.titleCase)(item.query)} repo memory ${variant + 1}`,
13323
13338
  summary: `Reusable learning about ${item.query}: ${item.lesson}`,
13324
13339
  body: [
13325
13340
  `During a real agent session, this durable repo learning was captured for ${item.query}.`,
@@ -13404,32 +13419,11 @@ function codingQualityByCategory(perQuery, metricsK) {
13404
13419
  return Array.from(groups.entries()).map(([category, rows]) => ({
13405
13420
  category,
13406
13421
  queries: rows.length,
13407
- ...Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, roundDecimal(averageNumber(rows.map((item) => item.recall[`at_${k}`] ?? 0)), 2)])),
13408
- ndcg_at_10: roundDecimal(averageNumber(rows.map((item) => item.ndcg_at_10)), 4),
13409
- mrr: roundDecimal(averageNumber(rows.map((item) => item.mrr)), 4),
13422
+ ...Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.recall[`at_${k}`] ?? 0)), 2)])),
13423
+ ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.ndcg_at_10)), 4),
13424
+ mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.mrr)), 4),
13410
13425
  }));
13411
13426
  }
13412
- function codingRecallAt(retrieved, relevant, k) {
13413
- if (!relevant.size)
13414
- return 0;
13415
- return retrieved.slice(0, k).filter((item) => relevant.has(item.packet_id)).length / relevant.size;
13416
- }
13417
- function codingPrecisionAt(retrieved, relevant, k) {
13418
- const rows = retrieved.slice(0, k);
13419
- return rows.length ? rows.filter((item) => relevant.has(item.packet_id)).length / rows.length : 0;
13420
- }
13421
- function codingNdcgAt(retrieved, relevant, k) {
13422
- const dcg = retrieved.slice(0, k).reduce((sum, item, index) => sum + (relevant.has(item.packet_id) ? 1 / Math.log2(index + 2) : 0), 0);
13423
- const idealHits = Math.min(relevant.size, k);
13424
- let ideal = 0;
13425
- for (let index = 0; index < idealHits; index += 1)
13426
- ideal += 1 / Math.log2(index + 2);
13427
- return ideal ? dcg / ideal : 0;
13428
- }
13429
- function codingMrr(retrieved, relevant) {
13430
- const index = retrieved.findIndex((item) => relevant.has(item.packet_id));
13431
- return index >= 0 ? 1 / (index + 1) : 0;
13432
- }
13433
13427
  function codingTypeForCategory(category) {
13434
13428
  if (category === "runbook")
13435
13429
  return "runbook";
@@ -13442,31 +13436,6 @@ function codingTypeForCategory(category) {
13442
13436
  function codingFileForTopic(topic, variant) {
13443
13437
  return `src/${slugify(topic)}-${variant % 3}.ts`;
13444
13438
  }
13445
- function averageNumber(values) {
13446
- return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
13447
- }
13448
- function percentileNumber(values, p) {
13449
- if (!values.length)
13450
- return 0;
13451
- const sorted = values.slice().sort((a, b) => a - b);
13452
- const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1));
13453
- return sorted[index];
13454
- }
13455
- function roundDecimal(value, digits = 2) {
13456
- const factor = 10 ** digits;
13457
- return Math.round(value * factor) / factor;
13458
- }
13459
- function countByKey(rows, fn) {
13460
- const counts = {};
13461
- for (const row of rows) {
13462
- const key = fn(row);
13463
- counts[key] = (counts[key] ?? 0) + 1;
13464
- }
13465
- return counts;
13466
- }
13467
- function titleCase(value) {
13468
- return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
13469
- }
13470
13439
  function baselineDiscoveryFiles(projectDir, task) {
13471
13440
  const terms = tokenize(task);
13472
13441
  const graph = buildCodeGraph(projectDir);
@@ -14750,6 +14719,36 @@ function verifyAgentActivation(agent, projectDir, options = {}) {
14750
14719
  function observationPath(projectDir, id) {
14751
14720
  return (0, node_path_1.join)(observationsDir(projectDir), `${id}.json`);
14752
14721
  }
14722
+ // Observations are session-scoped raw signal: distill consumes them at session end and
14723
+ // the resume digest only reads the recent window. Without retention the directory grows
14724
+ // forever (measured: 12k files / 48MB in two months of dogfooding), so refresh prunes
14725
+ // records older than the retention window. 0 disables pruning.
14726
+ const OBSERVATION_RETENTION_DAYS = (() => {
14727
+ const raw = Number(process.env.KAGE_OBSERVATION_RETENTION_DAYS ?? "30");
14728
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30;
14729
+ })();
14730
+ function pruneObservations(projectDir, maxAgeDays = OBSERVATION_RETENTION_DAYS) {
14731
+ if (maxAgeDays <= 0)
14732
+ return { pruned: 0 };
14733
+ const dir = observationsDir(projectDir);
14734
+ if (!(0, node_fs_1.existsSync)(dir))
14735
+ return { pruned: 0 };
14736
+ const cutoff = Date.now() - maxAgeDays * 86_400_000;
14737
+ let pruned = 0;
14738
+ for (const name of (0, node_fs_1.readdirSync)(dir)) {
14739
+ if (!name.endsWith(".json"))
14740
+ continue;
14741
+ const path = (0, node_path_1.join)(dir, name);
14742
+ try {
14743
+ if ((0, node_fs_1.statSync)(path).mtimeMs < cutoff) {
14744
+ (0, node_fs_1.unlinkSync)(path);
14745
+ pruned += 1;
14746
+ }
14747
+ }
14748
+ catch { /* concurrent removal — skip */ }
14749
+ }
14750
+ return { pruned };
14751
+ }
14753
14752
  function observationHash(projectDir, event) {
14754
14753
  const bucket = event.timestamp ? new Date(event.timestamp).toISOString().slice(0, 16) : nowIso().slice(0, 16);
14755
14754
  return (0, node_crypto_1.createHash)("sha256")
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ // Pure metric and statistics helpers used by the benchmark/metrics code.
3
+ // This is a dependency-free leaf module: it imports nothing from the kernel,
4
+ // so pulling it out carries no circular-dependency risk. Leaf-first is the
5
+ // safe way to decompose the kernel — extract the bottom of the dependency
6
+ // graph first, where nothing depends back inward.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.codingRecallAt = codingRecallAt;
9
+ exports.codingPrecisionAt = codingPrecisionAt;
10
+ exports.codingNdcgAt = codingNdcgAt;
11
+ exports.codingMrr = codingMrr;
12
+ exports.averageNumber = averageNumber;
13
+ exports.percentileNumber = percentileNumber;
14
+ exports.roundDecimal = roundDecimal;
15
+ exports.countByKey = countByKey;
16
+ exports.titleCase = titleCase;
17
+ /** Recall@k: fraction of the relevant set retrieved within the top k. */
18
+ function codingRecallAt(retrieved, relevant, k) {
19
+ if (!relevant.size)
20
+ return 0;
21
+ return retrieved.slice(0, k).filter((item) => relevant.has(item.packet_id)).length / relevant.size;
22
+ }
23
+ /** Precision@k: fraction of the top k that is relevant. */
24
+ function codingPrecisionAt(retrieved, relevant, k) {
25
+ const rows = retrieved.slice(0, k);
26
+ return rows.length ? rows.filter((item) => relevant.has(item.packet_id)).length / rows.length : 0;
27
+ }
28
+ /** Normalized discounted cumulative gain at k (binary relevance). */
29
+ function codingNdcgAt(retrieved, relevant, k) {
30
+ const dcg = retrieved.slice(0, k).reduce((sum, item, index) => sum + (relevant.has(item.packet_id) ? 1 / Math.log2(index + 2) : 0), 0);
31
+ const idealHits = Math.min(relevant.size, k);
32
+ let ideal = 0;
33
+ for (let index = 0; index < idealHits; index += 1)
34
+ ideal += 1 / Math.log2(index + 2);
35
+ return ideal ? dcg / ideal : 0;
36
+ }
37
+ /** Mean reciprocal rank: 1 / rank of the first relevant hit, else 0. */
38
+ function codingMrr(retrieved, relevant) {
39
+ const index = retrieved.findIndex((item) => relevant.has(item.packet_id));
40
+ return index >= 0 ? 1 / (index + 1) : 0;
41
+ }
42
+ /** Arithmetic mean, 0 for an empty list. */
43
+ function averageNumber(values) {
44
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
45
+ }
46
+ /** p-th percentile (0..1) via nearest-rank, 0 for an empty list. */
47
+ function percentileNumber(values, p) {
48
+ if (!values.length)
49
+ return 0;
50
+ const sorted = values.slice().sort((a, b) => a - b);
51
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1));
52
+ return sorted[index];
53
+ }
54
+ /** Round to a fixed number of decimal places. */
55
+ function roundDecimal(value, digits = 2) {
56
+ const factor = 10 ** digits;
57
+ return Math.round(value * factor) / factor;
58
+ }
59
+ /** Tally rows by a string key. */
60
+ function countByKey(rows, fn) {
61
+ const counts = {};
62
+ for (const row of rows) {
63
+ const key = fn(row);
64
+ counts[key] = (counts[key] ?? 0) + 1;
65
+ }
66
+ return counts;
67
+ }
68
+ /** Upper-case the first letter of each word. */
69
+ function titleCase(value) {
70
+ return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
71
+ }
package/dist/okf.js CHANGED
@@ -25,6 +25,7 @@ exports.okfConceptFileName = okfConceptFileName;
25
25
  exports.okfBundleDir = okfBundleDir;
26
26
  exports.migratePacketsToOkf = migratePacketsToOkf;
27
27
  exports.loadOkfConcepts = loadOkfConcepts;
28
+ exports.okfViewerHtml = okfViewerHtml;
28
29
  exports.lintOkfBundle = lintOkfBundle;
29
30
  const node_crypto_1 = require("node:crypto");
30
31
  const node_fs_1 = require("node:fs");
@@ -368,6 +369,102 @@ function loadOkfConcepts(dir, opts = {}) {
368
369
  }
369
370
  return out;
370
371
  }
372
+ // A clean, self-contained OKF bundle viewer — one HTML file with the concepts
373
+ // inlined, so it opens directly in a browser (no server, no giant URL). Renders
374
+ // concepts as filterable cards with their OKF type, resource, and verification
375
+ // status; click for the body. This is the "view your memory as an OKF bundle"
376
+ // experience, and it works on ANY OKF bundle, not just Kage's.
377
+ function okfViewerHtml(concepts, opts = {}) {
378
+ const rows = concepts.map((p) => ({
379
+ t: okfType(p.type),
380
+ title: p.title,
381
+ desc: p.summary || "",
382
+ resource: p.paths?.[0] || "",
383
+ tags: p.tags || [],
384
+ v: okfVerifiedStatus(p),
385
+ body: p.body || "",
386
+ updated: (p.updated_at || "").slice(0, 10),
387
+ }));
388
+ const data = JSON.stringify(rows).replace(/<\/(script)/gi, "<\\/$1");
389
+ const title = (opts.title || "Kage").replace(/[<>&"]/g, "");
390
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
391
+ <meta name="viewport" content="width=device-width,initial-scale=1">
392
+ <title>OKF bundle — ${title}</title>
393
+ <style>
394
+ :root{--g:#1f9d57;--ink:#16201a;--mut:#5b665e;--line:#e3e6e0;--paper:#fff;--bg:#f7f8f6;--code:#0c110d}
395
+ *{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Inter,sans-serif;color:var(--ink);background:var(--bg)}
396
+ header{padding:26px 24px 18px;border-bottom:1px solid var(--line);background:var(--paper)}
397
+ .wrap{max-width:1120px;margin:0 auto}
398
+ .brand{display:flex;align-items:center;gap:9px;font-weight:600;font-size:13px;color:var(--mut)}
399
+ .brand .dot{width:17px;height:17px;border-radius:5px;border:1.5px solid var(--g);position:relative}
400
+ .brand .dot::after{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--g);opacity:.5}
401
+ h1{margin:10px 0 2px;font-size:22px}
402
+ .stats{color:var(--mut);font-size:13.5px}.stats b{color:var(--ink)}
403
+ .controls{max-width:1120px;margin:16px auto 0;padding:0 24px;display:flex;flex-wrap:wrap;gap:10px;align-items:center}
404
+ #q{flex:1;min-width:200px;padding:9px 13px;border:1px solid var(--line);border-radius:9px;font-size:14px;background:var(--paper);color:var(--ink)}
405
+ #chips{display:flex;flex-wrap:wrap;gap:7px}
406
+ .chip{padding:6px 11px;border:1px solid var(--line);border-radius:999px;background:var(--paper);color:var(--mut);font-size:12.5px;cursor:pointer}
407
+ .chip.on{border-color:var(--g);color:var(--g);font-weight:600}
408
+ main#grid{max-width:1120px;margin:18px auto 60px;padding:0 24px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
409
+ .card{padding:15px 16px;border:1px solid var(--line);border-radius:12px;background:var(--paper);cursor:pointer;transition:border-color .12s}
410
+ .card:hover{border-color:var(--g)}
411
+ .meta{display:flex;align-items:center;gap:8px}
412
+ .badge{font:600 10.5px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.05em;color:var(--g);background:rgba(31,157,87,.1);padding:4px 7px;border-radius:5px}
413
+ .pill{margin-left:auto;font:600 11px/1 ui-monospace,monospace;padding:3px 8px;border-radius:999px}
414
+ .pill.ok{color:#0c7a4d;background:rgba(12,122,77,.12)}.pill.warn{color:#9a6b08;background:rgba(154,107,8,.14)}.pill.dim{color:var(--mut);background:rgba(0,0,0,.05)}
415
+ .ti{margin-top:9px;font-weight:600;font-size:15px;line-height:1.3}
416
+ .res{margin-top:6px;font:12px/1.4 ui-monospace,monospace;color:#155e9c;word-break:break-all}
417
+ .ds{margin-top:7px;color:var(--mut);font-size:13px}
418
+ .empty{color:var(--mut);padding:50px;text-align:center;grid-column:1/-1}
419
+ .detail{position:fixed;top:0;right:0;width:min(560px,93vw);height:100vh;overflow:auto;background:var(--paper);border-left:1px solid var(--line);box-shadow:-8px 0 34px rgba(0,0,0,.1);padding:24px 26px 60px;z-index:10}
420
+ .detail.hidden{display:none}
421
+ .detail .x{position:absolute;top:13px;right:16px;border:0;background:none;font-size:25px;color:var(--mut);cursor:pointer;line-height:1}
422
+ .detail h2{margin:12px 0 4px;font-size:19px}
423
+ .tags{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px}.tags span{font:11px/1 ui-monospace,monospace;color:var(--mut);background:rgba(0,0,0,.05);padding:4px 7px;border-radius:5px}
424
+ .detail pre{margin-top:16px;padding:14px 15px;background:var(--code);color:#c7cfc4;border-radius:10px;white-space:pre-wrap;font:12.5px/1.7 ui-monospace,monospace}
425
+ .upd{font:11px/1 ui-monospace,monospace;color:var(--mut)}
426
+ @media (prefers-color-scheme:dark){:root{--ink:#e7ebe4;--mut:#9aa49c;--line:#2a2f2a;--bg:#0e110e}body{background:var(--bg)}header,.card,#q,.chip,.detail{background:#161a16}.badge{background:rgba(31,157,87,.16)}.pill.dim{background:rgba(255,255,255,.07)}.tags span{background:rgba(255,255,255,.07)}}
427
+ </style></head><body>
428
+ <header><div class="wrap">
429
+ <div class="brand"><span class="dot"></span> Open Knowledge Format · viewed with Kage</div>
430
+ <h1>${title} — memory bundle</h1>
431
+ <div class="stats"><b id="n">0</b> concepts · <b id="nv">0</b> verified · <b id="nd">0</b> need attention</div>
432
+ </div></header>
433
+ <div class="controls"><input id="q" placeholder="Search concepts…" autocomplete="off"><div id="chips"></div></div>
434
+ <main id="grid"></main>
435
+ <div id="detail" class="detail hidden"></div>
436
+ <script>
437
+ var C=${data};
438
+ (function(){
439
+ var grid=document.getElementById('grid'),q=document.getElementById('q'),chips=document.getElementById('chips'),detail=document.getElementById('detail'),active='all';
440
+ function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(m){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m];});}
441
+ function vClass(v){return v==='fresh'||v==='verified'?'ok':(v==='drifted'||v==='stale'?'warn':'dim');}
442
+ var nv=0,nd=0;C.forEach(function(c){if(c.v==='fresh'||c.v==='verified')nv++;if(c.v==='drifted'||c.v==='stale')nd++;});
443
+ document.getElementById('n').textContent=C.length;document.getElementById('nv').textContent=nv;document.getElementById('nd').textContent=nd;
444
+ var types={};C.forEach(function(c){types[c.t]=(types[c.t]||0)+1;});
445
+ function mkChip(label,key){var b=document.createElement('button');b.className='chip'+(key==='all'?' on':'');b.textContent=label;b.onclick=function(){active=key;[].forEach.call(chips.children,function(x){x.classList.remove('on');});b.classList.add('on');render();};chips.appendChild(b);}
446
+ mkChip('All '+C.length,'all');Object.keys(types).sort().forEach(function(t){mkChip(t+' '+types[t],t);});
447
+ q.oninput=render;
448
+ function render(){var term=q.value.toLowerCase();grid.innerHTML='';var shown=0;
449
+ C.forEach(function(c){
450
+ if(active!=='all'&&c.t!==active)return;
451
+ if(term&&(c.title+' '+c.desc+' '+c.resource+' '+c.tags.join(' ')).toLowerCase().indexOf(term)<0)return;
452
+ shown++;var card=document.createElement('div');card.className='card';
453
+ card.innerHTML='<div class="meta"><span class="badge">'+esc(c.t)+'</span><span class="pill '+vClass(c.v)+'">'+esc(c.v)+'</span></div><div class="ti">'+esc(c.title)+'</div>'+(c.resource?'<div class="res">'+esc(c.resource)+'</div>':'')+'<div class="ds">'+esc(c.desc.slice(0,150))+'</div>';
454
+ card.onclick=function(){show(c);};grid.appendChild(card);
455
+ });
456
+ if(!shown)grid.innerHTML='<div class="empty">No concepts match.</div>';
457
+ }
458
+ function show(c){
459
+ detail.innerHTML='<button class="x" aria-label="Close" onclick="document.getElementById(\\'detail\\').classList.add(\\'hidden\\')">&times;</button><div class="meta"><span class="badge">'+esc(c.t)+'</span><span class="pill '+vClass(c.v)+'">'+esc(c.v)+'</span>'+(c.updated?'<span class="upd" style="margin-left:8px">'+esc(c.updated)+'</span>':'')+'</div><h2>'+esc(c.title)+'</h2>'+(c.resource?'<div class="res">resource · '+esc(c.resource)+'</div>':'')+(c.tags.length?'<div class="tags">'+c.tags.map(function(t){return '<span>'+esc(t)+'</span>';}).join('')+'</div>':'')+'<pre>'+esc(c.body)+'</pre>';
460
+ detail.classList.remove('hidden');
461
+ }
462
+ document.addEventListener('keydown',function(e){if(e.key==='Escape')detail.classList.add('hidden');});
463
+ render();
464
+ })();
465
+ </script></body></html>
466
+ `;
467
+ }
371
468
  function lintOkfBundle(dir) {
372
469
  const failures = [];
373
470
  let files = 0;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "3.0.0",
4
- "description": "Verified memory for coding agents, stored as a Google Open Knowledge Format (OKF) bundle and checked against your code. The verification layer OKF leaves out. MCP server, no account, no API key.",
3
+ "version": "3.2.0",
4
+ "description": "Agent memory on Google's Open Knowledge Format (OKF). Kage maintains your coding agents' OKF memory bundle in git and verifies every concept against your code, deterministically. The verification and freshness layer OKF leaves out. MCP server, no account, no API key.",
5
5
  "main": "dist/index.js",
6
6
  "files": [
7
7
  "dist/**/*.js",
package/viewer/console.js CHANGED
@@ -67,7 +67,7 @@
67
67
  // ---- nav ----
68
68
  var META = {
69
69
  dashboard: ["kage://dashboard", "Dashboard", "Your team's captured knowledge — decisions, runbooks, and fixes — at a glance."],
70
- gains: ["kage://gains", "What Kage saved you", "Tokens, dollars, and bad memories caught — receipts, not vibes."],
70
+ gains: ["kage://gains", "What Kage saved you", "Tokens saved and bad memories caught — receipts, not vibes."],
71
71
  overview: ["kage://trust", "Memory trust", "Whether this repo's agent memory can be trusted — at a glance."],
72
72
  graph: ["kage://memory-map", "Memory ↔ code map", "Each packet anchored to the files it's grounded in. Hover a node to inspect."],
73
73
  memory: ["kage://memory", "Memory", "Every packet Kage has stored, with health and grounding."],
@@ -137,12 +137,6 @@
137
137
  // value.json is the raw ledger written by recall: { totals, events[] }. Windows are
138
138
  // recomputed here with the same rules as `kage gains` (today = local midnight,
139
139
  // 7d = rolling, all-time = totals so trimmed events never lose history).
140
- var DOLLARS_PER_MILLION_TOKENS = 15;
141
- function dollars(tokens) { return (tokens / 1e6) * DOLLARS_PER_MILLION_TOKENS; }
142
- function fmtDollars(tokens) {
143
- var d = dollars(tokens);
144
- return "$" + (d >= 100 ? Math.round(d) : d.toFixed(2));
145
- }
146
140
  function summarizeWindow(events, cutoff) {
147
141
  var w = { tokens_saved: 0, stale_withheld: 0, recalls: 0, caller_answers: 0 };
148
142
  events.forEach(function (e) {
@@ -175,7 +169,6 @@
175
169
  var big = el("div", "big");
176
170
  big.appendChild(el("b", null, all.tokens_saved ? "0" : "—"));
177
171
  big.appendChild(el("span", "unit", "tokens saved, all time"));
178
- if (all.tokens_saved) big.appendChild(el("span", "dollars", "≈ " + fmtDollars(all.tokens_saved)));
179
172
  hh.appendChild(big);
180
173
  hh.appendChild(el("p", "sub", all.tokens_saved
181
174
  ? "Context your agents did not have to re-read from source, because Kage served grounded memory instead."
@@ -186,7 +179,6 @@
186
179
  var w = el("div", "r-win");
187
180
  w.appendChild(el("div", "k", p[0]));
188
181
  w.appendChild(el("div", "v", fmt(p[1].tokens_saved) + " tok"));
189
- w.appendChild(el("div", "d", "≈ " + fmtDollars(p[1].tokens_saved)));
190
182
  wins.appendChild(w);
191
183
  });
192
184
  hero.appendChild(wins);
@@ -203,13 +195,13 @@
203
195
  lines.appendChild(row);
204
196
  });
205
197
  hero.appendChild(lines);
206
- hero.appendChild(el("div", "r-foot", "estimated at $" + DOLLARS_PER_MILLION_TOKENS + " per 1M input tokens · ledger: .agent_memory/reports/value.json · verify: kage gains"));
198
+ hero.appendChild(el("div", "r-foot", "ledger: .agent_memory/reports/value.json · verify: kage gains"));
207
199
  if (all.tokens_saved) countUp(big.querySelector("b"), all.tokens_saved, 900);
208
200
 
209
201
  if (tiles) {
210
202
  tiles.textContent = "";
211
203
  [
212
- { k: "Saved (7 days)", v: fmt(week.tokens_saved), s: " " + fmtDollars(week.tokens_saved) + " of context not re-read", cls: "green" },
204
+ { k: "Saved (7 days)", v: fmt(week.tokens_saved), s: "tokens of context not re-read", cls: "green" },
213
205
  { k: "Recalls served (7d)", v: fmt(week.recalls), s: fmt(all.recalls) + " all-time", cls: "green" },
214
206
  { k: "Stale caught", v: fmt(all.stale_withheld), s: all.stale_withheld ? "withheld before they misled an agent" : "nothing withheld yet", cls: all.stale_withheld ? "warn" : "" },
215
207
  { k: "Graph answers", v: fmt(all.caller_answers), s: "caller questions answered from the code graph", cls: "code" },
package/viewer/index.html CHANGED
@@ -69,14 +69,12 @@
69
69
  .receipt .r-hero .big { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
70
70
  .receipt .r-hero .big b { font: 600 64px/0.95 var(--serif); letter-spacing: -0.02em; color: var(--gain); font-variant-numeric: tabular-nums; }
71
71
  .receipt .r-hero .big .unit { color: var(--muted); font: 500 16px/1 var(--sans); }
72
- .receipt .r-hero .big .dollars { color: var(--ink); font: 600 22px/1 var(--serif); }
73
72
  .receipt .r-hero .sub { margin-top: 10px; color: var(--muted); font: 400 13.5px/1.5 var(--sans); max-width: 560px; }
74
73
  .receipt .r-windows { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px dashed var(--line-strong); }
75
74
  .receipt .r-win { padding: 16px 24px; border-left: 1px dashed var(--line-strong); }
76
75
  .receipt .r-win:first-child { border-left: 0; }
77
76
  .receipt .r-win .k { color: var(--faint); font: 600 10px/1 var(--mono); text-transform: uppercase; letter-spacing: 0.1em; }
78
77
  .receipt .r-win .v { margin-top: 8px; font: 600 24px/1 var(--serif); color: var(--ink); font-variant-numeric: tabular-nums; }
79
- .receipt .r-win .d { margin-top: 5px; color: var(--gain); font: 600 12px/1 var(--mono); }
80
78
  .receipt .r-lines { border-top: 1px dashed var(--line-strong); padding: 14px 24px 16px; }
81
79
  .receipt .r-line { display: flex; align-items: baseline; gap: 10px; padding: 5px 0; font: 400 13.5px/1.5 var(--sans); color: var(--ink); }
82
80
  .receipt .r-line .dots { flex: 1; border-bottom: 1px dotted var(--line-strong); transform: translateY(-4px); }
@@ -290,7 +288,7 @@
290
288
  <header class="head">
291
289
  <span class="eyebrow" id="eyebrow">kage://gains</span>
292
290
  <h1 id="title">What Kage saved you</h1>
293
- <p id="subtitle">Tokens, dollars, and bad memories caught — receipts, not vibes.</p>
291
+ <p id="subtitle">Tokens saved and bad memories caught — receipts, not vibes.</p>
294
292
  </header>
295
293
  <div class="wrap">
296
294
  <section class="section active" id="section-dashboard">