@context-vault/core 2.10.3 → 2.12.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/package.json +1 -1
- package/src/capture/file-ops.js +4 -0
- package/src/capture/index.js +26 -0
- package/src/constants.js +13 -0
- package/src/core/categories.js +11 -0
- package/src/core/config.js +32 -0
- package/src/core/frontmatter.js +2 -0
- package/src/core/status.js +165 -0
- package/src/core/telemetry.js +90 -0
- package/src/index/db.js +86 -9
- package/src/index/index.js +37 -1
- package/src/index.js +1 -1
- package/src/retrieve/index.js +81 -4
- package/src/server/tools/context-status.js +37 -3
- package/src/server/tools/get-context.js +23 -2
- package/src/server/tools/ingest-url.js +0 -11
- package/src/server/tools/list-context.js +7 -3
- package/src/server/tools/save-context.js +144 -13
- package/src/server/tools.js +13 -0
package/package.json
CHANGED
package/src/capture/file-ops.js
CHANGED
|
@@ -32,10 +32,12 @@ export function writeEntryFile(
|
|
|
32
32
|
tags,
|
|
33
33
|
source,
|
|
34
34
|
createdAt,
|
|
35
|
+
updatedAt,
|
|
35
36
|
folder,
|
|
36
37
|
category,
|
|
37
38
|
identity_key,
|
|
38
39
|
expires_at,
|
|
40
|
+
supersedes,
|
|
39
41
|
},
|
|
40
42
|
) {
|
|
41
43
|
// P5: folder is now a top-level param; also accept from meta for backward compat
|
|
@@ -61,9 +63,11 @@ export function writeEntryFile(
|
|
|
61
63
|
|
|
62
64
|
if (identity_key) fmFields.identity_key = identity_key;
|
|
63
65
|
if (expires_at) fmFields.expires_at = expires_at;
|
|
66
|
+
if (supersedes?.length) fmFields.supersedes = supersedes;
|
|
64
67
|
fmFields.tags = tags || [];
|
|
65
68
|
fmFields.source = source || "claude-code";
|
|
66
69
|
fmFields.created = created;
|
|
70
|
+
if (updatedAt && updatedAt !== created) fmFields.updated = updatedAt;
|
|
67
71
|
|
|
68
72
|
const mdBody = formatBody(kind, { title, body, meta });
|
|
69
73
|
|
package/src/capture/index.js
CHANGED
|
@@ -26,6 +26,7 @@ export function writeEntry(
|
|
|
26
26
|
folder,
|
|
27
27
|
identity_key,
|
|
28
28
|
expires_at,
|
|
29
|
+
supersedes,
|
|
29
30
|
userId,
|
|
30
31
|
},
|
|
31
32
|
) {
|
|
@@ -47,6 +48,7 @@ export function writeEntry(
|
|
|
47
48
|
// Entity upsert: check for existing file at deterministic path
|
|
48
49
|
let id;
|
|
49
50
|
let createdAt;
|
|
51
|
+
let updatedAt;
|
|
50
52
|
if (category === "entity" && identity_key) {
|
|
51
53
|
const identitySlug = slugify(identity_key);
|
|
52
54
|
const dir = resolve(ctx.config.vaultDir, kindToPath(kind));
|
|
@@ -58,13 +60,16 @@ export function writeEntry(
|
|
|
58
60
|
const { meta: fmMeta } = parseFrontmatter(raw);
|
|
59
61
|
id = fmMeta.id || ulid();
|
|
60
62
|
createdAt = fmMeta.created || new Date().toISOString();
|
|
63
|
+
updatedAt = new Date().toISOString();
|
|
61
64
|
} else {
|
|
62
65
|
id = ulid();
|
|
63
66
|
createdAt = new Date().toISOString();
|
|
67
|
+
updatedAt = createdAt;
|
|
64
68
|
}
|
|
65
69
|
} else {
|
|
66
70
|
id = ulid();
|
|
67
71
|
createdAt = new Date().toISOString();
|
|
72
|
+
updatedAt = createdAt;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
const filePath = writeEntryFile(ctx.config.vaultDir, kind, {
|
|
@@ -75,10 +80,12 @@ export function writeEntry(
|
|
|
75
80
|
tags,
|
|
76
81
|
source,
|
|
77
82
|
createdAt,
|
|
83
|
+
updatedAt,
|
|
78
84
|
folder,
|
|
79
85
|
category,
|
|
80
86
|
identity_key,
|
|
81
87
|
expires_at,
|
|
88
|
+
supersedes,
|
|
82
89
|
});
|
|
83
90
|
|
|
84
91
|
return {
|
|
@@ -92,8 +99,10 @@ export function writeEntry(
|
|
|
92
99
|
tags,
|
|
93
100
|
source,
|
|
94
101
|
createdAt,
|
|
102
|
+
updatedAt,
|
|
95
103
|
identity_key,
|
|
96
104
|
expires_at,
|
|
105
|
+
supersedes,
|
|
97
106
|
userId: userId || null,
|
|
98
107
|
};
|
|
99
108
|
}
|
|
@@ -121,6 +130,10 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
121
130
|
updates.source !== undefined ? updates.source : existing.source;
|
|
122
131
|
const expires_at =
|
|
123
132
|
updates.expires_at !== undefined ? updates.expires_at : existing.expires_at;
|
|
133
|
+
const supersedes =
|
|
134
|
+
updates.supersedes !== undefined
|
|
135
|
+
? updates.supersedes
|
|
136
|
+
: fmMeta.supersedes || null;
|
|
124
137
|
|
|
125
138
|
let mergedMeta;
|
|
126
139
|
if (updates.meta !== undefined) {
|
|
@@ -130,6 +143,7 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
// Build frontmatter
|
|
146
|
+
const now = new Date().toISOString();
|
|
133
147
|
const fmFields = { id: existing.id };
|
|
134
148
|
for (const [k, v] of Object.entries(mergedMeta)) {
|
|
135
149
|
if (k === "folder") continue;
|
|
@@ -137,9 +151,11 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
137
151
|
}
|
|
138
152
|
if (existing.identity_key) fmFields.identity_key = existing.identity_key;
|
|
139
153
|
if (expires_at) fmFields.expires_at = expires_at;
|
|
154
|
+
if (supersedes?.length) fmFields.supersedes = supersedes;
|
|
140
155
|
fmFields.tags = tags;
|
|
141
156
|
fmFields.source = source || "claude-code";
|
|
142
157
|
fmFields.created = fmMeta.created || existing.created_at;
|
|
158
|
+
if (now !== fmFields.created) fmFields.updated = now;
|
|
143
159
|
|
|
144
160
|
const mdBody = formatBody(existing.kind, { title, body, meta: mergedMeta });
|
|
145
161
|
const md = formatFrontmatter(fmFields) + mdBody;
|
|
@@ -159,8 +175,10 @@ export function updateEntryFile(ctx, existing, updates) {
|
|
|
159
175
|
tags,
|
|
160
176
|
source,
|
|
161
177
|
createdAt: fmMeta.created || existing.created_at,
|
|
178
|
+
updatedAt: now,
|
|
162
179
|
identity_key: existing.identity_key,
|
|
163
180
|
expires_at,
|
|
181
|
+
supersedes,
|
|
164
182
|
userId: existing.user_id || null,
|
|
165
183
|
};
|
|
166
184
|
}
|
|
@@ -180,6 +198,14 @@ export async function captureAndIndex(ctx, data) {
|
|
|
180
198
|
const entry = writeEntry(ctx, data);
|
|
181
199
|
try {
|
|
182
200
|
await indexEntry(ctx, entry);
|
|
201
|
+
// Apply supersedes: mark referenced entries as superseded by this entry
|
|
202
|
+
if (entry.supersedes?.length && ctx.stmts.updateSupersededBy) {
|
|
203
|
+
for (const supersededId of entry.supersedes) {
|
|
204
|
+
if (typeof supersededId === "string" && supersededId.trim()) {
|
|
205
|
+
ctx.stmts.updateSupersededBy.run(entry.id, supersededId.trim());
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
183
209
|
return entry;
|
|
184
210
|
} catch (err) {
|
|
185
211
|
// Rollback: restore previous content for entity upserts, delete for new entries
|
package/src/constants.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export const APP_URL = "https://app.context-vault.com";
|
|
2
|
+
export const API_URL = "https://api.context-vault.com";
|
|
3
|
+
export const MARKETING_URL = "https://contextvault.dev";
|
|
4
|
+
export const GITHUB_ISSUES_URL =
|
|
5
|
+
"https://github.com/fellanH/context-vault/issues";
|
|
6
|
+
|
|
1
7
|
export const MAX_BODY_LENGTH = 100 * 1024; // 100KB
|
|
2
8
|
export const MAX_TITLE_LENGTH = 500;
|
|
3
9
|
export const MAX_KIND_LENGTH = 64;
|
|
@@ -6,3 +12,10 @@ export const MAX_TAGS_COUNT = 20;
|
|
|
6
12
|
export const MAX_META_LENGTH = 10 * 1024; // 10KB
|
|
7
13
|
export const MAX_SOURCE_LENGTH = 200;
|
|
8
14
|
export const MAX_IDENTITY_KEY_LENGTH = 200;
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_GROWTH_THRESHOLDS = {
|
|
17
|
+
totalEntries: { warn: 1000, critical: 5000 },
|
|
18
|
+
eventEntries: { warn: 500, critical: 2000 },
|
|
19
|
+
vaultSizeBytes: { warn: 50 * 1024 * 1024, critical: 200 * 1024 * 1024 },
|
|
20
|
+
eventsWithoutTtl: { warn: 200 },
|
|
21
|
+
};
|
package/src/core/categories.js
CHANGED
|
@@ -40,6 +40,17 @@ const CATEGORY_DIR_NAMES = {
|
|
|
40
40
|
/** Set of valid category directory names (for reindex discovery) */
|
|
41
41
|
export const CATEGORY_DIRS = new Set(Object.values(CATEGORY_DIR_NAMES));
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Staleness thresholds (in days) per knowledge kind.
|
|
45
|
+
* Kinds not listed here are considered enduring (no staleness threshold).
|
|
46
|
+
* Based on updated_at; falls back to created_at if updated_at is null.
|
|
47
|
+
*/
|
|
48
|
+
export const KIND_STALENESS_DAYS = {
|
|
49
|
+
pattern: 180,
|
|
50
|
+
decision: 365,
|
|
51
|
+
reference: 90,
|
|
52
|
+
};
|
|
53
|
+
|
|
43
54
|
export function categoryFor(kind) {
|
|
44
55
|
return KIND_CATEGORY[kind] || "knowledge";
|
|
45
56
|
}
|
package/src/core/config.js
CHANGED
|
@@ -1,6 +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
5
|
|
|
5
6
|
export function parseArgs(argv) {
|
|
6
7
|
const args = {};
|
|
@@ -31,6 +32,8 @@ export function resolveConfig() {
|
|
|
31
32
|
dbPath: join(dataDir, "vault.db"),
|
|
32
33
|
devDir: join(HOME, "dev"),
|
|
33
34
|
eventDecayDays: 30,
|
|
35
|
+
thresholds: { ...DEFAULT_GROWTH_THRESHOLDS },
|
|
36
|
+
telemetry: false,
|
|
34
37
|
resolvedFrom: "defaults",
|
|
35
38
|
};
|
|
36
39
|
|
|
@@ -46,6 +49,30 @@ export function resolveConfig() {
|
|
|
46
49
|
if (fc.dbPath) config.dbPath = fc.dbPath;
|
|
47
50
|
if (fc.devDir) config.devDir = fc.devDir;
|
|
48
51
|
if (fc.eventDecayDays != null) config.eventDecayDays = fc.eventDecayDays;
|
|
52
|
+
if (fc.thresholds) {
|
|
53
|
+
const t = fc.thresholds;
|
|
54
|
+
if (t.totalEntries)
|
|
55
|
+
config.thresholds.totalEntries = {
|
|
56
|
+
...config.thresholds.totalEntries,
|
|
57
|
+
...t.totalEntries,
|
|
58
|
+
};
|
|
59
|
+
if (t.eventEntries)
|
|
60
|
+
config.thresholds.eventEntries = {
|
|
61
|
+
...config.thresholds.eventEntries,
|
|
62
|
+
...t.eventEntries,
|
|
63
|
+
};
|
|
64
|
+
if (t.vaultSizeBytes)
|
|
65
|
+
config.thresholds.vaultSizeBytes = {
|
|
66
|
+
...config.thresholds.vaultSizeBytes,
|
|
67
|
+
...t.vaultSizeBytes,
|
|
68
|
+
};
|
|
69
|
+
if (t.eventsWithoutTtl)
|
|
70
|
+
config.thresholds.eventsWithoutTtl = {
|
|
71
|
+
...config.thresholds.eventsWithoutTtl,
|
|
72
|
+
...t.eventsWithoutTtl,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (fc.telemetry != null) config.telemetry = fc.telemetry === true;
|
|
49
76
|
// Hosted account linking (Phase 4)
|
|
50
77
|
if (fc.hostedUrl) config.hostedUrl = fc.hostedUrl;
|
|
51
78
|
if (fc.apiKey) config.apiKey = fc.apiKey;
|
|
@@ -96,6 +123,11 @@ export function resolveConfig() {
|
|
|
96
123
|
if (process.env.CONTEXT_VAULT_HOSTED_URL) {
|
|
97
124
|
config.hostedUrl = process.env.CONTEXT_VAULT_HOSTED_URL;
|
|
98
125
|
}
|
|
126
|
+
if (process.env.CONTEXT_VAULT_TELEMETRY !== undefined) {
|
|
127
|
+
config.telemetry =
|
|
128
|
+
process.env.CONTEXT_VAULT_TELEMETRY === "1" ||
|
|
129
|
+
process.env.CONTEXT_VAULT_TELEMETRY === "true";
|
|
130
|
+
}
|
|
99
131
|
|
|
100
132
|
if (cliArgs.vaultDir) {
|
|
101
133
|
config.vaultDir = cliArgs.vaultDir;
|
package/src/core/frontmatter.js
CHANGED
package/src/core/status.js
CHANGED
|
@@ -6,6 +6,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { walkDir } from "./files.js";
|
|
8
8
|
import { isEmbedAvailable } from "../index/embed.js";
|
|
9
|
+
import { KIND_STALENESS_DAYS } from "./categories.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Gather raw vault status data for formatting by consumers.
|
|
@@ -109,6 +110,30 @@ export function gatherVaultStatus(ctx, opts = {}) {
|
|
|
109
110
|
errors.push(`Expired count failed: ${e.message}`);
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// Count event-category entries
|
|
114
|
+
let eventCount = 0;
|
|
115
|
+
try {
|
|
116
|
+
eventCount = db
|
|
117
|
+
.prepare(
|
|
118
|
+
`SELECT COUNT(*) as c FROM vault WHERE category = 'event' ${userAnd}`,
|
|
119
|
+
)
|
|
120
|
+
.get(...userParams).c;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
errors.push(`Event count failed: ${e.message}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Count event entries without expires_at
|
|
126
|
+
let eventsWithoutTtlCount = 0;
|
|
127
|
+
try {
|
|
128
|
+
eventsWithoutTtlCount = db
|
|
129
|
+
.prepare(
|
|
130
|
+
`SELECT COUNT(*) as c FROM vault WHERE category = 'event' AND expires_at IS NULL ${userAnd}`,
|
|
131
|
+
)
|
|
132
|
+
.get(...userParams).c;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
errors.push(`Events without TTL count failed: ${e.message}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
// Embedding/vector status
|
|
113
138
|
let embeddingStatus = null;
|
|
114
139
|
try {
|
|
@@ -140,6 +165,27 @@ export function gatherVaultStatus(ctx, opts = {}) {
|
|
|
140
165
|
errors.push(`Auto-captured feedback count failed: ${e.message}`);
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
// Stale knowledge entries — kinds with a threshold, not updated within N days
|
|
169
|
+
let staleKnowledge = [];
|
|
170
|
+
try {
|
|
171
|
+
const stalenessKinds = Object.entries(KIND_STALENESS_DAYS);
|
|
172
|
+
if (stalenessKinds.length > 0) {
|
|
173
|
+
const kindClauses = stalenessKinds
|
|
174
|
+
.map(
|
|
175
|
+
([kind, days]) =>
|
|
176
|
+
`(kind = '${kind}' AND COALESCE(updated_at, created_at) <= datetime('now', '-${days} days'))`,
|
|
177
|
+
)
|
|
178
|
+
.join(" OR ");
|
|
179
|
+
staleKnowledge = db
|
|
180
|
+
.prepare(
|
|
181
|
+
`SELECT kind, title, COALESCE(updated_at, created_at) as last_updated FROM vault WHERE category = 'knowledge' AND (${kindClauses}) AND (expires_at IS NULL OR expires_at > datetime('now')) ${userAnd} ORDER BY last_updated ASC LIMIT 10`,
|
|
182
|
+
)
|
|
183
|
+
.all(...userParams);
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
errors.push(`Stale knowledge check failed: ${e.message}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
143
189
|
return {
|
|
144
190
|
fileCount,
|
|
145
191
|
subdirs,
|
|
@@ -150,10 +196,129 @@ export function gatherVaultStatus(ctx, opts = {}) {
|
|
|
150
196
|
stalePaths,
|
|
151
197
|
staleCount,
|
|
152
198
|
expiredCount,
|
|
199
|
+
eventCount,
|
|
200
|
+
eventsWithoutTtlCount,
|
|
153
201
|
embeddingStatus,
|
|
154
202
|
embedModelAvailable,
|
|
155
203
|
autoCapturedFeedbackCount,
|
|
204
|
+
staleKnowledge,
|
|
156
205
|
resolvedFrom: config.resolvedFrom,
|
|
157
206
|
errors,
|
|
158
207
|
};
|
|
159
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Compute growth warnings based on vault status and configured thresholds.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} status — result of gatherVaultStatus()
|
|
214
|
+
* @param {object} thresholds — from config.thresholds
|
|
215
|
+
* @returns {{ warnings: Array, hasCritical: boolean, hasWarnings: boolean, actions: string[] }}
|
|
216
|
+
*/
|
|
217
|
+
export function computeGrowthWarnings(status, thresholds) {
|
|
218
|
+
if (!thresholds)
|
|
219
|
+
return {
|
|
220
|
+
warnings: [],
|
|
221
|
+
hasCritical: false,
|
|
222
|
+
hasWarnings: false,
|
|
223
|
+
actions: [],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const t = thresholds;
|
|
227
|
+
const warnings = [];
|
|
228
|
+
const actions = [];
|
|
229
|
+
|
|
230
|
+
const total = status.embeddingStatus?.total ?? 0;
|
|
231
|
+
const {
|
|
232
|
+
eventCount = 0,
|
|
233
|
+
eventsWithoutTtlCount = 0,
|
|
234
|
+
expiredCount = 0,
|
|
235
|
+
dbSizeBytes = 0,
|
|
236
|
+
} = status;
|
|
237
|
+
|
|
238
|
+
if (t.totalEntries?.critical != null && total >= t.totalEntries.critical) {
|
|
239
|
+
warnings.push({
|
|
240
|
+
level: "critical",
|
|
241
|
+
message: `Total entries: ${total.toLocaleString()} (exceeds critical limit of ${t.totalEntries.critical.toLocaleString()})`,
|
|
242
|
+
});
|
|
243
|
+
} else if (t.totalEntries?.warn != null && total >= t.totalEntries.warn) {
|
|
244
|
+
warnings.push({
|
|
245
|
+
level: "warn",
|
|
246
|
+
message: `Total entries: ${total.toLocaleString()} (exceeds recommended ${t.totalEntries.warn.toLocaleString()})`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (
|
|
251
|
+
t.eventEntries?.critical != null &&
|
|
252
|
+
eventCount >= t.eventEntries.critical
|
|
253
|
+
) {
|
|
254
|
+
warnings.push({
|
|
255
|
+
level: "critical",
|
|
256
|
+
message: `Event entries: ${eventCount.toLocaleString()} (exceeds critical limit of ${t.eventEntries.critical.toLocaleString()})`,
|
|
257
|
+
});
|
|
258
|
+
} else if (
|
|
259
|
+
t.eventEntries?.warn != null &&
|
|
260
|
+
eventCount >= t.eventEntries.warn
|
|
261
|
+
) {
|
|
262
|
+
const ttlNote =
|
|
263
|
+
eventsWithoutTtlCount > 0
|
|
264
|
+
? ` (${eventsWithoutTtlCount.toLocaleString()} without TTL)`
|
|
265
|
+
: "";
|
|
266
|
+
warnings.push({
|
|
267
|
+
level: "warn",
|
|
268
|
+
message: `Event entries: ${eventCount.toLocaleString()}${ttlNote} (exceeds recommended ${t.eventEntries.warn.toLocaleString()})`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (
|
|
273
|
+
t.vaultSizeBytes?.critical != null &&
|
|
274
|
+
dbSizeBytes >= t.vaultSizeBytes.critical
|
|
275
|
+
) {
|
|
276
|
+
warnings.push({
|
|
277
|
+
level: "critical",
|
|
278
|
+
message: `Database size: ${(dbSizeBytes / 1024 / 1024).toFixed(1)}MB (exceeds critical limit of ${(t.vaultSizeBytes.critical / 1024 / 1024).toFixed(0)}MB)`,
|
|
279
|
+
});
|
|
280
|
+
} else if (
|
|
281
|
+
t.vaultSizeBytes?.warn != null &&
|
|
282
|
+
dbSizeBytes >= t.vaultSizeBytes.warn
|
|
283
|
+
) {
|
|
284
|
+
warnings.push({
|
|
285
|
+
level: "warn",
|
|
286
|
+
message: `Database size: ${(dbSizeBytes / 1024 / 1024).toFixed(1)}MB (exceeds recommended ${(t.vaultSizeBytes.warn / 1024 / 1024).toFixed(0)}MB)`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (
|
|
291
|
+
t.eventsWithoutTtl?.warn != null &&
|
|
292
|
+
eventsWithoutTtlCount >= t.eventsWithoutTtl.warn
|
|
293
|
+
) {
|
|
294
|
+
warnings.push({
|
|
295
|
+
level: "warn",
|
|
296
|
+
message: `Event entries without expires_at: ${eventsWithoutTtlCount.toLocaleString()} (exceeds recommended ${t.eventsWithoutTtl.warn.toLocaleString()})`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const hasCritical = warnings.some((w) => w.level === "critical");
|
|
301
|
+
|
|
302
|
+
if (expiredCount > 0) {
|
|
303
|
+
actions.push(
|
|
304
|
+
`Run \`context-vault prune\` to remove ${expiredCount} expired event entr${expiredCount === 1 ? "y" : "ies"}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const eventThresholdExceeded =
|
|
308
|
+
eventCount >= (t.eventEntries?.warn ?? Infinity);
|
|
309
|
+
const ttlThresholdExceeded =
|
|
310
|
+
eventsWithoutTtlCount >= (t.eventsWithoutTtl?.warn ?? Infinity);
|
|
311
|
+
if (
|
|
312
|
+
eventsWithoutTtlCount > 0 &&
|
|
313
|
+
(eventThresholdExceeded || ttlThresholdExceeded)
|
|
314
|
+
) {
|
|
315
|
+
actions.push(
|
|
316
|
+
"Add `expires_at` to event/session entries to enable automatic cleanup",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (total >= (t.totalEntries?.warn ?? Infinity)) {
|
|
320
|
+
actions.push("Consider archiving events older than 90 days");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { warnings, hasCritical, hasWarnings: warnings.length > 0, actions };
|
|
324
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { API_URL, MARKETING_URL, GITHUB_ISSUES_URL } from "../constants.js";
|
|
4
|
+
|
|
5
|
+
const TELEMETRY_ENDPOINT = `${API_URL}/telemetry`;
|
|
6
|
+
const NOTICE_MARKER = ".telemetry-notice-shown";
|
|
7
|
+
const FEEDBACK_PROMPT_MARKER = ".feedback-prompt-shown";
|
|
8
|
+
|
|
9
|
+
export function isTelemetryEnabled(config) {
|
|
10
|
+
const envVal = process.env.CONTEXT_VAULT_TELEMETRY;
|
|
11
|
+
if (envVal !== undefined) return envVal === "1" || envVal === "true";
|
|
12
|
+
return config?.telemetry === true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fire-and-forget telemetry event. Never throws, never blocks.
|
|
17
|
+
* Payload contains only: event, code, tool, cv_version, node_version, platform, arch, ts.
|
|
18
|
+
* No message text, stack traces, vault content, file paths, or user identifiers.
|
|
19
|
+
*/
|
|
20
|
+
export function sendTelemetryEvent(config, payload) {
|
|
21
|
+
if (!isTelemetryEnabled(config)) return;
|
|
22
|
+
|
|
23
|
+
const event = {
|
|
24
|
+
event: payload.event,
|
|
25
|
+
code: payload.code || null,
|
|
26
|
+
tool: payload.tool || null,
|
|
27
|
+
cv_version: payload.cv_version,
|
|
28
|
+
node_version: process.version,
|
|
29
|
+
platform: process.platform,
|
|
30
|
+
arch: process.arch,
|
|
31
|
+
ts: new Date().toISOString(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
fetch(TELEMETRY_ENDPOINT, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
body: JSON.stringify(event),
|
|
38
|
+
signal: AbortSignal.timeout(5000),
|
|
39
|
+
}).catch(() => {});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Print the one-time telemetry notice to stderr.
|
|
44
|
+
* Uses a marker file in dataDir to ensure it's only shown once.
|
|
45
|
+
*/
|
|
46
|
+
export function maybeShowTelemetryNotice(dataDir) {
|
|
47
|
+
try {
|
|
48
|
+
const markerPath = join(dataDir, NOTICE_MARKER);
|
|
49
|
+
if (existsSync(markerPath)) return;
|
|
50
|
+
writeFileSync(markerPath, new Date().toISOString() + "\n");
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const lines = [
|
|
56
|
+
"[context-vault] Telemetry: disabled by default.",
|
|
57
|
+
"[context-vault] To help improve context-vault, you can opt in to anonymous error reporting.",
|
|
58
|
+
"[context-vault] Reports contain only: event type, error code, tool name, version, node version, platform, arch, timestamp.",
|
|
59
|
+
"[context-vault] No vault content, file paths, or personal data is ever sent.",
|
|
60
|
+
'[context-vault] Opt in: set "telemetry": true in ~/.context-mcp/config.json or set CONTEXT_VAULT_TELEMETRY=1.',
|
|
61
|
+
`[context-vault] Full payload schema: ${MARKETING_URL}/telemetry`,
|
|
62
|
+
];
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
process.stderr.write(line + "\n");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Print a one-time feedback prompt after the user's first successful save.
|
|
70
|
+
* Uses a marker file in dataDir to ensure it's only shown once.
|
|
71
|
+
* Never throws, never blocks.
|
|
72
|
+
*/
|
|
73
|
+
export function maybeShowFeedbackPrompt(dataDir) {
|
|
74
|
+
try {
|
|
75
|
+
const markerPath = join(dataDir, FEEDBACK_PROMPT_MARKER);
|
|
76
|
+
if (existsSync(markerPath)) return;
|
|
77
|
+
writeFileSync(markerPath, new Date().toISOString() + "\n");
|
|
78
|
+
} catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lines = [
|
|
83
|
+
"[context-vault] First entry saved — nice work!",
|
|
84
|
+
"[context-vault] Got feedback, a bug, or a feature request?",
|
|
85
|
+
`[context-vault] Open an issue: ${GITHUB_ISSUES_URL}`,
|
|
86
|
+
];
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
process.stderr.write(line + "\n");
|
|
89
|
+
}
|
|
90
|
+
}
|