@context-vault/core 2.15.0 → 2.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.15.0",
3
+ "version": "2.17.1",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "license": "MIT",
25
25
  "engines": {
26
- "node": ">=24"
26
+ "node": ">=20"
27
27
  },
28
28
  "author": "Felix Hellstrom",
29
29
  "repository": {
@@ -36,7 +36,6 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@anthropic-ai/sdk": "^0.78.0",
40
39
  "@modelcontextprotocol/sdk": "^1.26.0",
41
40
  "sqlite-vec": "^0.1.0"
42
41
  },
@@ -38,6 +38,7 @@ export function writeEntryFile(
38
38
  identity_key,
39
39
  expires_at,
40
40
  supersedes,
41
+ related_to,
41
42
  },
42
43
  ) {
43
44
  // P5: folder is now a top-level param; also accept from meta for backward compat
@@ -64,6 +65,7 @@ export function writeEntryFile(
64
65
  if (identity_key) fmFields.identity_key = identity_key;
65
66
  if (expires_at) fmFields.expires_at = expires_at;
66
67
  if (supersedes?.length) fmFields.supersedes = supersedes;
68
+ if (related_to?.length) fmFields.related_to = related_to;
67
69
  fmFields.tags = tags || [];
68
70
  fmFields.source = source || "claude-code";
69
71
  fmFields.created = created;
@@ -27,6 +27,7 @@ export function writeEntry(
27
27
  identity_key,
28
28
  expires_at,
29
29
  supersedes,
30
+ related_to,
30
31
  source_files,
31
32
  tier,
32
33
  userId,
@@ -88,6 +89,7 @@ export function writeEntry(
88
89
  identity_key,
89
90
  expires_at,
90
91
  supersedes,
92
+ related_to,
91
93
  });
92
94
 
93
95
  return {
@@ -105,6 +107,7 @@ export function writeEntry(
105
107
  identity_key,
106
108
  expires_at,
107
109
  supersedes,
110
+ related_to: related_to || null,
108
111
  source_files: source_files || null,
109
112
  tier: tier || null,
110
113
  userId: userId || null,
@@ -126,6 +129,9 @@ export function updateEntryFile(ctx, existing, updates) {
126
129
 
127
130
  const existingMeta = existing.meta ? JSON.parse(existing.meta) : {};
128
131
  const existingTags = existing.tags ? JSON.parse(existing.tags) : [];
132
+ const existingRelatedTo = existing.related_to
133
+ ? JSON.parse(existing.related_to)
134
+ : fmMeta.related_to || null;
129
135
 
130
136
  const title = updates.title !== undefined ? updates.title : existing.title;
131
137
  const body = updates.body !== undefined ? updates.body : existing.body;
@@ -138,6 +144,8 @@ export function updateEntryFile(ctx, existing, updates) {
138
144
  updates.supersedes !== undefined
139
145
  ? updates.supersedes
140
146
  : fmMeta.supersedes || null;
147
+ const related_to =
148
+ updates.related_to !== undefined ? updates.related_to : existingRelatedTo;
141
149
  const source_files =
142
150
  updates.source_files !== undefined
143
151
  ? updates.source_files
@@ -162,6 +170,7 @@ export function updateEntryFile(ctx, existing, updates) {
162
170
  if (existing.identity_key) fmFields.identity_key = existing.identity_key;
163
171
  if (expires_at) fmFields.expires_at = expires_at;
164
172
  if (supersedes?.length) fmFields.supersedes = supersedes;
173
+ if (related_to?.length) fmFields.related_to = related_to;
165
174
  fmFields.tags = tags;
166
175
  fmFields.source = source || "claude-code";
167
176
  fmFields.created = fmMeta.created || existing.created_at;
@@ -189,6 +198,7 @@ export function updateEntryFile(ctx, existing, updates) {
189
198
  identity_key: existing.identity_key,
190
199
  expires_at,
191
200
  supersedes,
201
+ related_to: related_to || null,
192
202
  source_files: source_files || null,
193
203
  userId: existing.user_id || null,
194
204
  };
@@ -217,6 +227,10 @@ export async function captureAndIndex(ctx, data) {
217
227
  }
218
228
  }
219
229
  }
230
+ // Store related_to links in DB
231
+ if (entry.related_to?.length && ctx.stmts.updateRelatedTo) {
232
+ ctx.stmts.updateRelatedTo.run(JSON.stringify(entry.related_to), entry.id);
233
+ }
220
234
  return entry;
221
235
  } catch (err) {
222
236
  // Rollback: restore previous content for entity upserts, delete for new entries
package/src/constants.js CHANGED
@@ -14,8 +14,13 @@ export const MAX_SOURCE_LENGTH = 200;
14
14
  export const MAX_IDENTITY_KEY_LENGTH = 200;
15
15
 
16
16
  export const DEFAULT_GROWTH_THRESHOLDS = {
17
- totalEntries: { warn: 1000, critical: 5000 },
18
- eventEntries: { warn: 500, critical: 2000 },
17
+ totalEntries: { warn: 2000, critical: 5000 },
18
+ eventEntries: { warn: 1000, critical: 3000 },
19
19
  vaultSizeBytes: { warn: 50 * 1024 * 1024, critical: 200 * 1024 * 1024 },
20
20
  eventsWithoutTtl: { warn: 200 },
21
21
  };
22
+
23
+ export const DEFAULT_LIFECYCLE = {
24
+ event: { archiveAfterDays: 90 },
25
+ ephemeral: { archiveAfterDays: 30 },
26
+ };
@@ -23,6 +23,7 @@ const KIND_CATEGORY = {
23
23
  source: "entity",
24
24
  bucket: "entity",
25
25
  // Event — append-only, decaying
26
+ event: "event",
26
27
  conversation: "event",
27
28
  message: "event",
28
29
  session: "event",
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { homedir } from "node:os";
4
- import { DEFAULT_GROWTH_THRESHOLDS } from "../constants.js";
4
+ import { DEFAULT_GROWTH_THRESHOLDS, DEFAULT_LIFECYCLE } from "../constants.js";
5
5
 
6
6
  export function parseArgs(argv) {
7
7
  const args = {};
@@ -27,7 +27,7 @@ export function resolveConfig() {
27
27
  join(HOME, ".context-mcp"),
28
28
  );
29
29
  const config = {
30
- vaultDir: join(HOME, "vault"),
30
+ vaultDir: join(HOME, ".vault"),
31
31
  dataDir,
32
32
  dbPath: join(dataDir, "vault.db"),
33
33
  devDir: join(HOME, "dev"),
@@ -48,6 +48,7 @@ export function resolveConfig() {
48
48
  maxAgeDays: 7,
49
49
  autoConsolidate: false,
50
50
  },
51
+ lifecycle: structuredClone(DEFAULT_LIFECYCLE),
51
52
  };
52
53
 
53
54
  const configPath = join(dataDir, "config.json");
@@ -62,6 +63,12 @@ export function resolveConfig() {
62
63
  if (fc.dbPath) config.dbPath = fc.dbPath;
63
64
  if (fc.devDir) config.devDir = fc.devDir;
64
65
  if (fc.eventDecayDays != null) config.eventDecayDays = fc.eventDecayDays;
66
+ if (fc.growthWarningThreshold != null) {
67
+ config.thresholds.totalEntries = {
68
+ ...config.thresholds.totalEntries,
69
+ warn: Number(fc.growthWarningThreshold),
70
+ };
71
+ }
65
72
  if (fc.thresholds) {
66
73
  const t = fc.thresholds;
67
74
  if (t.totalEntries)
package/src/core/files.js CHANGED
@@ -37,42 +37,19 @@ export function slugify(text, maxLen = 60) {
37
37
  return slug;
38
38
  }
39
39
 
40
- const PLURAL_MAP = {
41
- insight: "insights",
42
- decision: "decisions",
43
- pattern: "patterns",
44
- status: "statuses",
45
- analysis: "analyses",
46
- contact: "contacts",
47
- project: "projects",
48
- tool: "tools",
49
- source: "sources",
50
- conversation: "conversations",
51
- message: "messages",
52
- session: "sessions",
53
- log: "logs",
54
- feedback: "feedbacks",
55
- };
56
-
57
- const SINGULAR_MAP = Object.fromEntries(
58
- Object.entries(PLURAL_MAP).map(([k, v]) => [v, k]),
59
- );
60
-
40
+ /** Map kind name to its directory name. Kind names are used as-is (no pluralization). */
61
41
  export function kindToDir(kind) {
62
- if (PLURAL_MAP[kind]) return PLURAL_MAP[kind];
63
- return kind.endsWith("s") ? kind : kind + "s";
42
+ return kind;
64
43
  }
65
44
 
45
+ /** Map directory name back to kind name. Directory names equal kind names (identity). */
66
46
  export function dirToKind(dirName) {
67
- if (SINGULAR_MAP[dirName]) return SINGULAR_MAP[dirName];
68
- return dirName.replace(/s$/, "");
47
+ return dirName;
69
48
  }
70
49
 
71
- /** Normalize a kind input (singular or plural) to its canonical singular form. */
50
+ /** Normalize a kind input to its canonical form. Kind names are returned as-is. */
72
51
  export function normalizeKind(input) {
73
- if (PLURAL_MAP[input]) return input; // Already a known singular kind
74
- if (SINGULAR_MAP[input]) return SINGULAR_MAP[input]; // Known plural → singular
75
- return input; // Unknown — use as-is (don't strip 's')
52
+ return input;
76
53
  }
77
54
 
78
55
  /** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
@@ -69,6 +69,7 @@ const RESERVED_FM_KEYS = new Set([
69
69
  "identity_key",
70
70
  "expires_at",
71
71
  "supersedes",
72
+ "related_to",
72
73
  ]);
73
74
 
74
75
  export function extractCustomMeta(fmMeta) {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * linking.js — Pure graph traversal for related_to links.
3
+ *
4
+ * All functions accept a db handle and return data — no side effects.
5
+ * The calling layer (get-context handler) is responsible for I/O wiring.
6
+ */
7
+
8
+ /**
9
+ * Parse a `related_to` JSON string from the DB into an array of ID strings.
10
+ * Returns an empty array on any parse failure or null input.
11
+ *
12
+ * @param {string|null|undefined} raw
13
+ * @returns {string[]}
14
+ */
15
+ export function parseRelatedTo(raw) {
16
+ if (!raw) return [];
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ if (!Array.isArray(parsed)) return [];
20
+ return parsed.filter((id) => typeof id === "string" && id.trim());
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Fetch vault entries by their IDs, scoped to a user.
28
+ * Returns only entries that exist and are not expired or superseded.
29
+ *
30
+ * @param {import('node:sqlite').DatabaseSync} db
31
+ * @param {string[]} ids
32
+ * @param {string|null|undefined} userId
33
+ * @returns {object[]} Matching DB rows
34
+ */
35
+ export function resolveLinks(db, ids, userId) {
36
+ if (!ids.length) return [];
37
+ const unique = [...new Set(ids)];
38
+ const placeholders = unique.map(() => "?").join(",");
39
+ // When userId is defined (hosted mode), scope to that user.
40
+ // When userId is undefined (local mode), no user scoping — all entries accessible.
41
+ const userClause =
42
+ userId !== undefined && userId !== null ? "AND user_id = ?" : "";
43
+ const params =
44
+ userId !== undefined && userId !== null ? [...unique, userId] : unique;
45
+ try {
46
+ return db
47
+ .prepare(
48
+ `SELECT * FROM vault
49
+ WHERE id IN (${placeholders})
50
+ ${userClause}
51
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
52
+ AND superseded_by IS NULL`,
53
+ )
54
+ .all(...params);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Find all entries that declare `entryId` in their `related_to` field
62
+ * (i.e. entries that point *to* this entry — backlinks).
63
+ * Scoped to the same user. Excludes expired and superseded entries.
64
+ *
65
+ * @param {import('node:sqlite').DatabaseSync} db
66
+ * @param {string} entryId
67
+ * @param {string|null|undefined} userId
68
+ * @returns {object[]} Entries with a backlink to entryId
69
+ */
70
+ export function resolveBacklinks(db, entryId, userId) {
71
+ if (!entryId) return [];
72
+ // When userId is defined (hosted mode), scope to that user.
73
+ // When userId is undefined (local mode), no user scoping — all entries accessible.
74
+ const userClause =
75
+ userId !== undefined && userId !== null ? "AND user_id = ?" : "";
76
+ const likePattern = `%"${entryId}"%`;
77
+ const params =
78
+ userId !== undefined && userId !== null
79
+ ? [likePattern, userId]
80
+ : [likePattern];
81
+ try {
82
+ return db
83
+ .prepare(
84
+ `SELECT * FROM vault
85
+ WHERE related_to LIKE ?
86
+ ${userClause}
87
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
88
+ AND superseded_by IS NULL`,
89
+ )
90
+ .all(...params);
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /**
97
+ * For a set of primary entry IDs, collect all forward links (entries pointed
98
+ * to by `related_to`) and backlinks (entries that point back to any primary).
99
+ *
100
+ * Returns a Map of id → entry row for all linked entries, excluding entries
101
+ * already present in `primaryIds`.
102
+ *
103
+ * @param {import('node:sqlite').DatabaseSync} db
104
+ * @param {object[]} primaryEntries - Full entry rows (must have id + related_to fields)
105
+ * @param {string|null|undefined} userId
106
+ * @returns {{ forward: object[], backward: object[] }}
107
+ */
108
+ export function collectLinkedEntries(db, primaryEntries, userId) {
109
+ const primaryIds = new Set(primaryEntries.map((e) => e.id));
110
+
111
+ // Forward: resolve all IDs from related_to fields
112
+ const forwardIds = [];
113
+ for (const entry of primaryEntries) {
114
+ const ids = parseRelatedTo(entry.related_to);
115
+ for (const id of ids) {
116
+ if (!primaryIds.has(id)) forwardIds.push(id);
117
+ }
118
+ }
119
+ const forwardEntries = resolveLinks(db, forwardIds, userId).filter(
120
+ (e) => !primaryIds.has(e.id),
121
+ );
122
+
123
+ // Backward: find all entries that link to any primary entry
124
+ const backwardSeen = new Set();
125
+ const backwardEntries = [];
126
+ const forwardIds2 = new Set(forwardEntries.map((e) => e.id));
127
+ for (const entry of primaryEntries) {
128
+ const backlinks = resolveBacklinks(db, entry.id, userId);
129
+ for (const bl of backlinks) {
130
+ if (!primaryIds.has(bl.id) && !backwardSeen.has(bl.id)) {
131
+ backwardSeen.add(bl.id);
132
+ backwardEntries.push(bl);
133
+ }
134
+ }
135
+ }
136
+
137
+ return { forward: forwardEntries, backward: backwardEntries };
138
+ }
139
+
140
+ /**
141
+ * Validate a `related_to` value from user input.
142
+ * Must be an array of non-empty strings (ULID-like IDs).
143
+ * Returns an error message string if invalid, or null if valid.
144
+ *
145
+ * @param {unknown} relatedTo
146
+ * @returns {string|null}
147
+ */
148
+ export function validateRelatedTo(relatedTo) {
149
+ if (relatedTo === undefined || relatedTo === null) return null;
150
+ if (!Array.isArray(relatedTo))
151
+ return "related_to must be an array of entry IDs";
152
+ for (const id of relatedTo) {
153
+ if (typeof id !== "string" || !id.trim()) {
154
+ return "each related_to entry must be a non-empty string ID";
155
+ }
156
+ if (id.length > 32) {
157
+ return `related_to ID too long (max 32 chars): "${id.slice(0, 32)}..."`;
158
+ }
159
+ }
160
+ return null;
161
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * migrate-dirs.js — Rename plural vault directories to singular
3
+ *
4
+ * After context-vault >= 2.18.0, kindToDir() returns singular names.
5
+ * Existing vaults still have plural dirs (e.g. knowledge/decisions/).
6
+ * This module plans and executes the rename/merge migration.
7
+ *
8
+ * Architecture: pure planning function + I/O execution function.
9
+ */
10
+
11
+ import {
12
+ existsSync,
13
+ readdirSync,
14
+ mkdirSync,
15
+ renameSync,
16
+ copyFileSync,
17
+ rmSync,
18
+ statSync,
19
+ } from "node:fs";
20
+ import { join, basename } from "node:path";
21
+
22
+ /**
23
+ * Complete plural→singular mapping for vault directory names.
24
+ * Covers the old PLURAL_MAP from files.js plus extended kinds seen in the wild.
25
+ *
26
+ * @type {Record<string, string>}
27
+ */
28
+ export const PLURAL_TO_SINGULAR = {
29
+ // From old PLURAL_MAP in files.js
30
+ insights: "insight",
31
+ decisions: "decision",
32
+ patterns: "pattern",
33
+ statuses: "status",
34
+ analyses: "analysis",
35
+ contacts: "contact",
36
+ projects: "project",
37
+ tools: "tool",
38
+ sources: "source",
39
+ conversations: "conversation",
40
+ messages: "message",
41
+ sessions: "session",
42
+ logs: "log",
43
+ feedbacks: "feedback",
44
+ // Extended kinds from categories.js + observed in vaults
45
+ notes: "note",
46
+ prompts: "prompt",
47
+ documents: "document",
48
+ references: "reference",
49
+ tasks: "task",
50
+ buckets: "bucket",
51
+ architectures: "architecture",
52
+ briefs: "brief",
53
+ companies: "company",
54
+ discoveries: "discovery",
55
+ events: "event",
56
+ ideas: "idea",
57
+ issues: "issue",
58
+ agents: "agent",
59
+ "session-summaries": "session-summary",
60
+ "session-reviews": "session-review",
61
+ "user-prompts": "user-prompt",
62
+ };
63
+
64
+ /**
65
+ * Category directory names that are scanned for plural kind subdirectories.
66
+ */
67
+ const CATEGORY_DIRS = ["knowledge", "entities", "events"];
68
+
69
+ /**
70
+ * Count .md files recursively in a directory.
71
+ *
72
+ * @param {string} dir
73
+ * @returns {number}
74
+ */
75
+ function countMdFiles(dir) {
76
+ let count = 0;
77
+ try {
78
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
79
+ if (entry.isDirectory()) {
80
+ count += countMdFiles(join(dir, entry.name));
81
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
82
+ count++;
83
+ }
84
+ }
85
+ } catch {
86
+ // ignore unreadable dirs
87
+ }
88
+ return count;
89
+ }
90
+
91
+ /**
92
+ * Plan migration operations: walk the vault and detect plural dirs that need renaming.
93
+ * Pure I/O read-only — does not modify any files.
94
+ *
95
+ * @param {string} vaultDir - Absolute path to vault root
96
+ * @returns {MigrationOp[]} Array of planned operations
97
+ *
98
+ * @typedef {{ action: 'rename'|'merge', pluralDir: string, singularDir: string, pluralName: string, singularName: string, fileCount: number }} MigrationOp
99
+ */
100
+ export function planMigration(vaultDir) {
101
+ const ops = [];
102
+
103
+ for (const catName of CATEGORY_DIRS) {
104
+ const catDir = join(vaultDir, catName);
105
+ if (!existsSync(catDir) || !statSync(catDir).isDirectory()) continue;
106
+
107
+ let entries;
108
+ try {
109
+ entries = readdirSync(catDir, { withFileTypes: true });
110
+ } catch {
111
+ continue;
112
+ }
113
+
114
+ for (const entry of entries) {
115
+ if (!entry.isDirectory()) continue;
116
+ const dirName = entry.name;
117
+ const singular = PLURAL_TO_SINGULAR[dirName];
118
+
119
+ // Not a known plural — skip (might already be singular or unknown kind)
120
+ if (!singular) continue;
121
+
122
+ const pluralDir = join(catDir, dirName);
123
+ const singularDir = join(catDir, singular);
124
+
125
+ // Guard: plural and singular are the same (shouldn't happen but be safe)
126
+ if (pluralDir === singularDir) continue;
127
+
128
+ const fileCount = countMdFiles(pluralDir);
129
+ const singularExists = existsSync(singularDir);
130
+
131
+ ops.push({
132
+ action: singularExists ? "merge" : "rename",
133
+ pluralDir,
134
+ singularDir,
135
+ pluralName: dirName,
136
+ singularName: singular,
137
+ fileCount,
138
+ });
139
+ }
140
+ }
141
+
142
+ return ops;
143
+ }
144
+
145
+ /**
146
+ * Copy all files and subdirectories from src into dst (non-overwriting).
147
+ * Mirrors `cp -rn src/* dst/`.
148
+ *
149
+ * @param {string} src
150
+ * @param {string} dst
151
+ */
152
+ function mergeDir(src, dst) {
153
+ mkdirSync(dst, { recursive: true });
154
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
155
+ const srcPath = join(src, entry.name);
156
+ const dstPath = join(dst, entry.name);
157
+ if (entry.isDirectory()) {
158
+ mergeDir(srcPath, dstPath);
159
+ } else if (entry.isFile()) {
160
+ if (!existsSync(dstPath)) {
161
+ copyFileSync(srcPath, dstPath);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Execute migration operations. Renames or merges plural dirs into singular dirs.
169
+ * Safe to call multiple times — already-renamed dirs produce no-op ops from planMigration.
170
+ *
171
+ * @param {MigrationOp[]} ops - Operations from planMigration()
172
+ * @returns {{ renamed: number, merged: number, errors: string[] }}
173
+ */
174
+ export function executeMigration(ops) {
175
+ let renamed = 0;
176
+ let merged = 0;
177
+ const errors = [];
178
+
179
+ for (const op of ops) {
180
+ try {
181
+ if (op.action === "rename") {
182
+ renameSync(op.pluralDir, op.singularDir);
183
+ renamed++;
184
+ } else {
185
+ // merge: copy files from plural into singular, then remove plural
186
+ mergeDir(op.pluralDir, op.singularDir);
187
+ rmSync(op.pluralDir, { recursive: true, force: true });
188
+ merged++;
189
+ }
190
+ } catch (e) {
191
+ errors.push(`${op.pluralName} → ${op.singularName}: ${e.message}`);
192
+ }
193
+ }
194
+
195
+ return { renamed, merged, errors };
196
+ }
@@ -212,7 +212,7 @@ export function gatherVaultStatus(ctx, opts = {}) {
212
212
  *
213
213
  * @param {object} status — result of gatherVaultStatus()
214
214
  * @param {object} thresholds — from config.thresholds
215
- * @returns {{ warnings: Array, hasCritical: boolean, hasWarnings: boolean, actions: string[] }}
215
+ * @returns {{ warnings: Array, hasCritical: boolean, hasWarnings: boolean, actions: string[], kindBreakdown: Array }}
216
216
  */
217
217
  export function computeGrowthWarnings(status, thresholds) {
218
218
  if (!thresholds)
@@ -221,6 +221,7 @@ export function computeGrowthWarnings(status, thresholds) {
221
221
  hasCritical: false,
222
222
  hasWarnings: false,
223
223
  actions: [],
224
+ kindBreakdown: [],
224
225
  };
225
226
 
226
227
  const t = thresholds;
@@ -235,12 +236,16 @@ export function computeGrowthWarnings(status, thresholds) {
235
236
  dbSizeBytes = 0,
236
237
  } = status;
237
238
 
239
+ let totalExceeded = false;
240
+
238
241
  if (t.totalEntries?.critical != null && total >= t.totalEntries.critical) {
242
+ totalExceeded = true;
239
243
  warnings.push({
240
244
  level: "critical",
241
245
  message: `Total entries: ${total.toLocaleString()} (exceeds critical limit of ${t.totalEntries.critical.toLocaleString()})`,
242
246
  });
243
247
  } else if (t.totalEntries?.warn != null && total >= t.totalEntries.warn) {
248
+ totalExceeded = true;
244
249
  warnings.push({
245
250
  level: "warn",
246
251
  message: `Total entries: ${total.toLocaleString()} (exceeds recommended ${t.totalEntries.warn.toLocaleString()})`,
@@ -320,5 +325,26 @@ export function computeGrowthWarnings(status, thresholds) {
320
325
  actions.push("Consider archiving events older than 90 days");
321
326
  }
322
327
 
323
- return { warnings, hasCritical, hasWarnings: warnings.length > 0, actions };
328
+ const kindBreakdown = totalExceeded
329
+ ? buildKindBreakdown(status.kindCounts, total)
330
+ : [];
331
+
332
+ return {
333
+ warnings,
334
+ hasCritical,
335
+ hasWarnings: warnings.length > 0,
336
+ actions,
337
+ kindBreakdown,
338
+ };
339
+ }
340
+
341
+ function buildKindBreakdown(kindCounts, total) {
342
+ if (!kindCounts?.length || total === 0) return [];
343
+ return [...kindCounts]
344
+ .sort((a, b) => b.c - a.c)
345
+ .map(({ kind, c }) => ({
346
+ kind,
347
+ count: c,
348
+ pct: Math.round((c / total) * 100),
349
+ }));
324
350
  }