@context-vault/core 2.10.3 → 2.11.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 +7 -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 +64 -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 +138 -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
|
@@ -6,3 +6,10 @@ export const MAX_TAGS_COUNT = 20;
|
|
|
6
6
|
export const MAX_META_LENGTH = 10 * 1024; // 10KB
|
|
7
7
|
export const MAX_SOURCE_LENGTH = 200;
|
|
8
8
|
export const MAX_IDENTITY_KEY_LENGTH = 200;
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_GROWTH_THRESHOLDS = {
|
|
11
|
+
totalEntries: { warn: 1000, critical: 5000 },
|
|
12
|
+
eventEntries: { warn: 500, critical: 2000 },
|
|
13
|
+
vaultSizeBytes: { warn: 50 * 1024 * 1024, critical: 200 * 1024 * 1024 },
|
|
14
|
+
eventsWithoutTtl: { warn: 200 },
|
|
15
|
+
};
|
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,64 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const TELEMETRY_ENDPOINT = "https://api.context-vault.com/telemetry";
|
|
5
|
+
const NOTICE_MARKER = ".telemetry-notice-shown";
|
|
6
|
+
|
|
7
|
+
export function isTelemetryEnabled(config) {
|
|
8
|
+
const envVal = process.env.CONTEXT_VAULT_TELEMETRY;
|
|
9
|
+
if (envVal !== undefined) return envVal === "1" || envVal === "true";
|
|
10
|
+
return config?.telemetry === true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fire-and-forget telemetry event. Never throws, never blocks.
|
|
15
|
+
* Payload contains only: event, code, tool, cv_version, node_version, platform, arch, ts.
|
|
16
|
+
* No message text, stack traces, vault content, file paths, or user identifiers.
|
|
17
|
+
*/
|
|
18
|
+
export function sendTelemetryEvent(config, payload) {
|
|
19
|
+
if (!isTelemetryEnabled(config)) return;
|
|
20
|
+
|
|
21
|
+
const event = {
|
|
22
|
+
event: payload.event,
|
|
23
|
+
code: payload.code || null,
|
|
24
|
+
tool: payload.tool || null,
|
|
25
|
+
cv_version: payload.cv_version,
|
|
26
|
+
node_version: process.version,
|
|
27
|
+
platform: process.platform,
|
|
28
|
+
arch: process.arch,
|
|
29
|
+
ts: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
fetch(TELEMETRY_ENDPOINT, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify(event),
|
|
36
|
+
signal: AbortSignal.timeout(5000),
|
|
37
|
+
}).catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Print the one-time telemetry notice to stderr.
|
|
42
|
+
* Uses a marker file in dataDir to ensure it's only shown once.
|
|
43
|
+
*/
|
|
44
|
+
export function maybeShowTelemetryNotice(dataDir) {
|
|
45
|
+
try {
|
|
46
|
+
const markerPath = join(dataDir, NOTICE_MARKER);
|
|
47
|
+
if (existsSync(markerPath)) return;
|
|
48
|
+
writeFileSync(markerPath, new Date().toISOString() + "\n");
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lines = [
|
|
54
|
+
"[context-vault] Telemetry: disabled by default.",
|
|
55
|
+
"[context-vault] To help improve context-vault, you can opt in to anonymous error reporting.",
|
|
56
|
+
"[context-vault] Reports contain only: event type, error code, tool name, version, node version, platform, arch, timestamp.",
|
|
57
|
+
"[context-vault] No vault content, file paths, or personal data is ever sent.",
|
|
58
|
+
'[context-vault] Opt in: set "telemetry": true in ~/.context-mcp/config.json or set CONTEXT_VAULT_TELEMETRY=1.',
|
|
59
|
+
"[context-vault] Full payload schema: https://contextvault.dev/telemetry",
|
|
60
|
+
];
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
process.stderr.write(line + "\n");
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/index/db.js
CHANGED
|
@@ -55,7 +55,9 @@ export const SCHEMA_DDL = `
|
|
|
55
55
|
file_path TEXT UNIQUE,
|
|
56
56
|
identity_key TEXT,
|
|
57
57
|
expires_at TEXT,
|
|
58
|
+
superseded_by TEXT,
|
|
58
59
|
created_at TEXT DEFAULT (datetime('now')),
|
|
60
|
+
updated_at TEXT,
|
|
59
61
|
user_id TEXT,
|
|
60
62
|
team_id TEXT,
|
|
61
63
|
body_encrypted BLOB,
|
|
@@ -67,9 +69,11 @@ export const SCHEMA_DDL = `
|
|
|
67
69
|
CREATE INDEX IF NOT EXISTS idx_vault_kind ON vault(kind);
|
|
68
70
|
CREATE INDEX IF NOT EXISTS idx_vault_category ON vault(category);
|
|
69
71
|
CREATE INDEX IF NOT EXISTS idx_vault_category_created ON vault(category, created_at DESC);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_vault_updated ON vault(updated_at DESC);
|
|
70
73
|
CREATE INDEX IF NOT EXISTS idx_vault_user ON vault(user_id);
|
|
71
74
|
CREATE INDEX IF NOT EXISTS idx_vault_team ON vault(team_id);
|
|
72
75
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(user_id, kind, identity_key) WHERE identity_key IS NOT NULL;
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL;
|
|
73
77
|
|
|
74
78
|
-- Single FTS5 table
|
|
75
79
|
CREATE VIRTUAL TABLE IF NOT EXISTS vault_fts USING fts5(
|
|
@@ -147,13 +151,13 @@ export async function initDatabase(dbPath) {
|
|
|
147
151
|
|
|
148
152
|
const freshDb = createDb(dbPath);
|
|
149
153
|
freshDb.exec(SCHEMA_DDL);
|
|
150
|
-
freshDb.exec("PRAGMA user_version =
|
|
154
|
+
freshDb.exec("PRAGMA user_version = 9");
|
|
151
155
|
return freshDb;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
if (version < 5) {
|
|
155
159
|
db.exec(SCHEMA_DDL);
|
|
156
|
-
db.exec("PRAGMA user_version =
|
|
160
|
+
db.exec("PRAGMA user_version = 9");
|
|
157
161
|
} else if (version === 5) {
|
|
158
162
|
// v5 -> v6 migration: add multi-tenancy + encryption columns
|
|
159
163
|
// Wrapped in transaction with duplicate-column guards for idempotent retry
|
|
@@ -171,24 +175,91 @@ export async function initDatabase(dbPath) {
|
|
|
171
175
|
addColumnSafe(`ALTER TABLE vault ADD COLUMN meta_encrypted BLOB`);
|
|
172
176
|
addColumnSafe(`ALTER TABLE vault ADD COLUMN iv BLOB`);
|
|
173
177
|
addColumnSafe(`ALTER TABLE vault ADD COLUMN team_id TEXT`);
|
|
178
|
+
addColumnSafe(`ALTER TABLE vault ADD COLUMN updated_at TEXT`);
|
|
179
|
+
addColumnSafe(`ALTER TABLE vault ADD COLUMN superseded_by TEXT`);
|
|
174
180
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_user ON vault(user_id)`);
|
|
175
181
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_team ON vault(team_id)`);
|
|
176
182
|
db.exec(`DROP INDEX IF EXISTS idx_vault_identity`);
|
|
177
183
|
db.exec(
|
|
178
184
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(user_id, kind, identity_key) WHERE identity_key IS NOT NULL`,
|
|
179
185
|
);
|
|
180
|
-
db.exec(
|
|
186
|
+
db.exec(
|
|
187
|
+
`UPDATE vault SET updated_at = created_at WHERE updated_at IS NULL`,
|
|
188
|
+
);
|
|
189
|
+
db.exec(
|
|
190
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_updated ON vault(updated_at DESC)`,
|
|
191
|
+
);
|
|
192
|
+
db.exec(
|
|
193
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL`,
|
|
194
|
+
);
|
|
195
|
+
db.exec("PRAGMA user_version = 9");
|
|
181
196
|
});
|
|
182
197
|
} else if (version === 6) {
|
|
183
|
-
// v6 -> v7 migration: add team_id
|
|
198
|
+
// v6 -> v7+v8+v9 migration: add team_id, updated_at, superseded_by columns
|
|
184
199
|
runTransaction(db, () => {
|
|
185
200
|
try {
|
|
186
201
|
db.exec(`ALTER TABLE vault ADD COLUMN team_id TEXT`);
|
|
187
202
|
} catch (e) {
|
|
188
203
|
if (!e.message.includes("duplicate column")) throw e;
|
|
189
204
|
}
|
|
205
|
+
try {
|
|
206
|
+
db.exec(`ALTER TABLE vault ADD COLUMN updated_at TEXT`);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (!e.message.includes("duplicate column")) throw e;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
db.exec(`ALTER TABLE vault ADD COLUMN superseded_by TEXT`);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
if (!e.message.includes("duplicate column")) throw e;
|
|
214
|
+
}
|
|
190
215
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_vault_team ON vault(team_id)`);
|
|
191
|
-
db.exec(
|
|
216
|
+
db.exec(
|
|
217
|
+
`UPDATE vault SET updated_at = created_at WHERE updated_at IS NULL`,
|
|
218
|
+
);
|
|
219
|
+
db.exec(
|
|
220
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_updated ON vault(updated_at DESC)`,
|
|
221
|
+
);
|
|
222
|
+
db.exec(
|
|
223
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL`,
|
|
224
|
+
);
|
|
225
|
+
db.exec("PRAGMA user_version = 9");
|
|
226
|
+
});
|
|
227
|
+
} else if (version === 7) {
|
|
228
|
+
// v7 -> v8+v9 migration: add updated_at, superseded_by columns
|
|
229
|
+
runTransaction(db, () => {
|
|
230
|
+
try {
|
|
231
|
+
db.exec(`ALTER TABLE vault ADD COLUMN updated_at TEXT`);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (!e.message.includes("duplicate column")) throw e;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
db.exec(`ALTER TABLE vault ADD COLUMN superseded_by TEXT`);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (!e.message.includes("duplicate column")) throw e;
|
|
239
|
+
}
|
|
240
|
+
db.exec(
|
|
241
|
+
`UPDATE vault SET updated_at = created_at WHERE updated_at IS NULL`,
|
|
242
|
+
);
|
|
243
|
+
db.exec(
|
|
244
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_updated ON vault(updated_at DESC)`,
|
|
245
|
+
);
|
|
246
|
+
db.exec(
|
|
247
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL`,
|
|
248
|
+
);
|
|
249
|
+
db.exec("PRAGMA user_version = 9");
|
|
250
|
+
});
|
|
251
|
+
} else if (version === 8) {
|
|
252
|
+
// v8 -> v9 migration: add superseded_by column
|
|
253
|
+
runTransaction(db, () => {
|
|
254
|
+
try {
|
|
255
|
+
db.exec(`ALTER TABLE vault ADD COLUMN superseded_by TEXT`);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (!e.message.includes("duplicate column")) throw e;
|
|
258
|
+
}
|
|
259
|
+
db.exec(
|
|
260
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL`,
|
|
261
|
+
);
|
|
262
|
+
db.exec("PRAGMA user_version = 9");
|
|
192
263
|
});
|
|
193
264
|
}
|
|
194
265
|
|
|
@@ -199,13 +270,13 @@ export function prepareStatements(db) {
|
|
|
199
270
|
try {
|
|
200
271
|
return {
|
|
201
272
|
insertEntry: db.prepare(
|
|
202
|
-
`INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
273
|
+
`INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
203
274
|
),
|
|
204
275
|
insertEntryEncrypted: db.prepare(
|
|
205
|
-
`INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, body_encrypted, title_encrypted, meta_encrypted, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
276
|
+
`INSERT INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at, body_encrypted, title_encrypted, meta_encrypted, iv) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
206
277
|
),
|
|
207
278
|
updateEntry: db.prepare(
|
|
208
|
-
`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at =
|
|
279
|
+
`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at = ?, updated_at = datetime('now') WHERE file_path = ?`,
|
|
209
280
|
),
|
|
210
281
|
deleteEntry: db.prepare(`DELETE FROM vault WHERE id = ?`),
|
|
211
282
|
getRowid: db.prepare(`SELECT rowid FROM vault WHERE id = ?`),
|
|
@@ -215,12 +286,18 @@ export function prepareStatements(db) {
|
|
|
215
286
|
`SELECT * FROM vault WHERE kind = ? AND identity_key = ? AND user_id IS ?`,
|
|
216
287
|
),
|
|
217
288
|
upsertByIdentityKey: db.prepare(
|
|
218
|
-
`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at =
|
|
289
|
+
`UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at = ?, updated_at = datetime('now') WHERE kind = ? AND identity_key = ? AND user_id IS ?`,
|
|
219
290
|
),
|
|
220
291
|
insertVecStmt: db.prepare(
|
|
221
292
|
`INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`,
|
|
222
293
|
),
|
|
223
294
|
deleteVecStmt: db.prepare(`DELETE FROM vault_vec WHERE rowid = ?`),
|
|
295
|
+
updateSupersededBy: db.prepare(
|
|
296
|
+
`UPDATE vault SET superseded_by = ? WHERE id = ?`,
|
|
297
|
+
),
|
|
298
|
+
clearSupersededByRef: db.prepare(
|
|
299
|
+
`UPDATE vault SET superseded_by = NULL WHERE superseded_by = ?`,
|
|
300
|
+
),
|
|
224
301
|
};
|
|
225
302
|
} catch (e) {
|
|
226
303
|
throw new Error(
|
package/src/index/index.js
CHANGED
|
@@ -111,6 +111,7 @@ export async function indexEntry(
|
|
|
111
111
|
identity_key || null,
|
|
112
112
|
expires_at || null,
|
|
113
113
|
createdAt,
|
|
114
|
+
createdAt,
|
|
114
115
|
encrypted.body_encrypted,
|
|
115
116
|
encrypted.title_encrypted,
|
|
116
117
|
encrypted.meta_encrypted,
|
|
@@ -131,6 +132,7 @@ export async function indexEntry(
|
|
|
131
132
|
identity_key || null,
|
|
132
133
|
expires_at || null,
|
|
133
134
|
createdAt,
|
|
135
|
+
createdAt,
|
|
134
136
|
);
|
|
135
137
|
}
|
|
136
138
|
} catch (e) {
|
|
@@ -186,6 +188,39 @@ export async function indexEntry(
|
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Prune expired entries: delete files, vec rows, and DB rows for all entries
|
|
193
|
+
* where expires_at <= now(). Safe to call on startup or CLI — non-destructive
|
|
194
|
+
* to active data.
|
|
195
|
+
*
|
|
196
|
+
* @param {import('../server/types.js').BaseCtx} ctx
|
|
197
|
+
* @returns {Promise<number>} count of pruned entries
|
|
198
|
+
*/
|
|
199
|
+
export async function pruneExpired(ctx) {
|
|
200
|
+
const expired = ctx.db
|
|
201
|
+
.prepare(
|
|
202
|
+
"SELECT id, file_path FROM vault WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')",
|
|
203
|
+
)
|
|
204
|
+
.all();
|
|
205
|
+
|
|
206
|
+
for (const row of expired) {
|
|
207
|
+
if (row.file_path) {
|
|
208
|
+
try {
|
|
209
|
+
unlinkSync(row.file_path);
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
const vRowid = ctx.stmts.getRowid.get(row.id)?.rowid;
|
|
213
|
+
if (vRowid) {
|
|
214
|
+
try {
|
|
215
|
+
ctx.deleteVec(Number(vRowid));
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
ctx.stmts.deleteEntry.run(row.id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return expired.length;
|
|
222
|
+
}
|
|
223
|
+
|
|
189
224
|
/**
|
|
190
225
|
* Bulk reindex: sync vault directory into the database.
|
|
191
226
|
* P2: Wrapped in a transaction for atomicity.
|
|
@@ -205,7 +240,7 @@ export async function reindex(ctx, opts = {}) {
|
|
|
205
240
|
// Use INSERT OR IGNORE for reindex — handles files with duplicate frontmatter IDs
|
|
206
241
|
// user_id is NULL for reindex (always local mode)
|
|
207
242
|
const upsertEntry = ctx.db.prepare(
|
|
208
|
-
`INSERT OR IGNORE INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
243
|
+
`INSERT OR IGNORE INTO vault (id, user_id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
209
244
|
);
|
|
210
245
|
|
|
211
246
|
// Auto-discover kind directories, supporting both:
|
|
@@ -309,6 +344,7 @@ export async function reindex(ctx, opts = {}) {
|
|
|
309
344
|
identity_key,
|
|
310
345
|
expires_at,
|
|
311
346
|
created,
|
|
347
|
+
fmMeta.updated || created,
|
|
312
348
|
);
|
|
313
349
|
if (result.changes > 0) {
|
|
314
350
|
const rowidResult = ctx.stmts.getRowid.get(id);
|
package/src/index.js
CHANGED
|
@@ -47,7 +47,7 @@ export {
|
|
|
47
47
|
deleteVec,
|
|
48
48
|
} from "./index/db.js";
|
|
49
49
|
export { embed, embedBatch, resetEmbedPipeline } from "./index/embed.js";
|
|
50
|
-
export { indexEntry, reindex } from "./index/index.js";
|
|
50
|
+
export { indexEntry, reindex, pruneExpired } from "./index/index.js";
|
|
51
51
|
|
|
52
52
|
// Retrieve layer
|
|
53
53
|
export { hybridSearch } from "./retrieve/index.js";
|
package/src/retrieve/index.js
CHANGED
|
@@ -9,10 +9,23 @@
|
|
|
9
9
|
|
|
10
10
|
const FTS_WEIGHT = 0.4;
|
|
11
11
|
const VEC_WEIGHT = 0.6;
|
|
12
|
+
const NEAR_DUP_THRESHOLD = 0.92;
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
|
|
15
|
+
* Dot product of two Float32Array vectors (cosine similarity for unit vectors).
|
|
16
|
+
*/
|
|
17
|
+
export function dotProduct(a, b) {
|
|
18
|
+
let sum = 0;
|
|
19
|
+
for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
|
|
20
|
+
return sum;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a tiered FTS5 query that prioritises phrase match, then proximity,
|
|
25
|
+
* then AND. Multi-word queries become:
|
|
26
|
+
* "word1 word2" OR NEAR("word1" "word2", 10) OR "word1" AND "word2"
|
|
27
|
+
* Single-word queries remain a simple quoted term.
|
|
28
|
+
* Returns null if no valid words remain after stripping FTS5 metacharacters.
|
|
16
29
|
*/
|
|
17
30
|
export function buildFtsQuery(query) {
|
|
18
31
|
const words = query
|
|
@@ -20,7 +33,11 @@ export function buildFtsQuery(query) {
|
|
|
20
33
|
.map((w) => w.replace(/[*"():^~{}]/g, ""))
|
|
21
34
|
.filter((w) => w.length > 0);
|
|
22
35
|
if (!words.length) return null;
|
|
23
|
-
|
|
36
|
+
if (words.length === 1) return `"${words[0]}"`;
|
|
37
|
+
const phrase = `"${words.join(" ")}"`;
|
|
38
|
+
const near = `NEAR(${words.map((w) => `"${w}"`).join(" ")}, 10)`;
|
|
39
|
+
const and = words.map((w) => `"${w}"`).join(" AND ");
|
|
40
|
+
return `${phrase} OR ${near} OR ${and}`;
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
/**
|
|
@@ -44,6 +61,7 @@ export function buildFilterClauses({
|
|
|
44
61
|
until,
|
|
45
62
|
userIdFilter,
|
|
46
63
|
teamIdFilter,
|
|
64
|
+
includeSuperseeded = false,
|
|
47
65
|
}) {
|
|
48
66
|
const clauses = [];
|
|
49
67
|
const params = [];
|
|
@@ -68,6 +86,9 @@ export function buildFilterClauses({
|
|
|
68
86
|
params.push(until);
|
|
69
87
|
}
|
|
70
88
|
clauses.push("(e.expires_at IS NULL OR e.expires_at > datetime('now'))");
|
|
89
|
+
if (!includeSuperseeded) {
|
|
90
|
+
clauses.push("e.superseded_by IS NULL");
|
|
91
|
+
}
|
|
71
92
|
return { clauses, params };
|
|
72
93
|
}
|
|
73
94
|
|
|
@@ -92,15 +113,19 @@ export async function hybridSearch(
|
|
|
92
113
|
decayDays = 30,
|
|
93
114
|
userIdFilter,
|
|
94
115
|
teamIdFilter = null,
|
|
116
|
+
includeSuperseeded = false,
|
|
95
117
|
} = {},
|
|
96
118
|
) {
|
|
97
119
|
const results = new Map();
|
|
120
|
+
const idToRowid = new Map();
|
|
121
|
+
let queryVec = null;
|
|
98
122
|
const extraFilters = buildFilterClauses({
|
|
99
123
|
categoryFilter,
|
|
100
124
|
since,
|
|
101
125
|
until,
|
|
102
126
|
userIdFilter,
|
|
103
127
|
teamIdFilter,
|
|
128
|
+
includeSuperseeded,
|
|
104
129
|
});
|
|
105
130
|
|
|
106
131
|
// FTS5 search
|
|
@@ -144,7 +169,7 @@ export async function hybridSearch(
|
|
|
144
169
|
.prepare("SELECT COUNT(*) as c FROM vault_vec")
|
|
145
170
|
.get().c;
|
|
146
171
|
if (vecCount > 0) {
|
|
147
|
-
|
|
172
|
+
queryVec = await ctx.embed(query);
|
|
148
173
|
if (queryVec) {
|
|
149
174
|
// Increase limits in hosted mode to compensate for post-filtering
|
|
150
175
|
const hasPostFilter = userIdFilter !== undefined || teamIdFilter;
|
|
@@ -188,6 +213,7 @@ export async function hybridSearch(
|
|
|
188
213
|
continue;
|
|
189
214
|
|
|
190
215
|
const { rowid: _rowid, ...cleanRow } = row;
|
|
216
|
+
idToRowid.set(cleanRow.id, Number(row.rowid));
|
|
191
217
|
// sqlite-vec returns L2 distance [0, 2] for normalized vectors.
|
|
192
218
|
// Convert to similarity [1, 0] with: 1 - distance/2
|
|
193
219
|
const vecScore = Math.max(0, 1 - vr.distance / 2) * VEC_WEIGHT;
|
|
@@ -215,5 +241,56 @@ export async function hybridSearch(
|
|
|
215
241
|
}
|
|
216
242
|
|
|
217
243
|
const sorted = [...results.values()].sort((a, b) => b.score - a.score);
|
|
244
|
+
|
|
245
|
+
// Near-duplicate suppression: when embeddings are available and we have more
|
|
246
|
+
// candidates than needed, skip results that are too similar to already-selected ones.
|
|
247
|
+
if (queryVec && idToRowid.size > 0 && sorted.length > limit) {
|
|
248
|
+
const rowidsToFetch = sorted
|
|
249
|
+
.filter((c) => idToRowid.has(c.id))
|
|
250
|
+
.map((c) => idToRowid.get(c.id));
|
|
251
|
+
|
|
252
|
+
const embeddingMap = new Map();
|
|
253
|
+
if (rowidsToFetch.length > 0) {
|
|
254
|
+
try {
|
|
255
|
+
const placeholders = rowidsToFetch.map(() => "?").join(",");
|
|
256
|
+
const vecData = ctx.db
|
|
257
|
+
.prepare(
|
|
258
|
+
`SELECT rowid, embedding FROM vault_vec WHERE rowid IN (${placeholders})`,
|
|
259
|
+
)
|
|
260
|
+
.all(...rowidsToFetch);
|
|
261
|
+
for (const row of vecData) {
|
|
262
|
+
const buf = row.embedding;
|
|
263
|
+
if (buf) {
|
|
264
|
+
embeddingMap.set(
|
|
265
|
+
Number(row.rowid),
|
|
266
|
+
new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch (_) {
|
|
271
|
+
return sorted.slice(offset, offset + limit);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const selected = [];
|
|
276
|
+
const selectedVecs = [];
|
|
277
|
+
for (const candidate of sorted) {
|
|
278
|
+
if (selected.length >= offset + limit) break;
|
|
279
|
+
const rowid = idToRowid.get(candidate.id);
|
|
280
|
+
const vec = rowid !== undefined ? embeddingMap.get(rowid) : null;
|
|
281
|
+
if (vec && selectedVecs.length > 0) {
|
|
282
|
+
let maxSim = 0;
|
|
283
|
+
for (const sv of selectedVecs) {
|
|
284
|
+
const sim = dotProduct(sv, vec);
|
|
285
|
+
if (sim > maxSim) maxSim = sim;
|
|
286
|
+
}
|
|
287
|
+
if (maxSim > NEAR_DUP_THRESHOLD) continue;
|
|
288
|
+
}
|
|
289
|
+
selected.push(candidate);
|
|
290
|
+
if (vec) selectedVecs.push(vec);
|
|
291
|
+
}
|
|
292
|
+
return selected.slice(offset, offset + limit);
|
|
293
|
+
}
|
|
294
|
+
|
|
218
295
|
return sorted.slice(offset, offset + limit);
|
|
219
296
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { gatherVaultStatus } from "../../core/status.js";
|
|
1
|
+
import { gatherVaultStatus, computeGrowthWarnings } from "../../core/status.js";
|
|
2
2
|
import { errorLogPath, errorLogCount } from "../../core/error-log.js";
|
|
3
3
|
import { ok } from "../helpers.js";
|
|
4
4
|
|
|
@@ -40,7 +40,7 @@ export function handler(_args, ctx) {
|
|
|
40
40
|
`Data dir: ${config.dataDir}`,
|
|
41
41
|
`Config: ${config.configPath}`,
|
|
42
42
|
`Resolved via: ${status.resolvedFrom}`,
|
|
43
|
-
`Schema:
|
|
43
|
+
`Schema: v9 (updated_at, superseded_by)`,
|
|
44
44
|
];
|
|
45
45
|
|
|
46
46
|
if (status.embeddingStatus) {
|
|
@@ -58,7 +58,7 @@ export function handler(_args, ctx) {
|
|
|
58
58
|
lines.push(`Decay: ${config.eventDecayDays} days (event recency window)`);
|
|
59
59
|
if (status.expiredCount > 0) {
|
|
60
60
|
lines.push(
|
|
61
|
-
`Expired: ${status.expiredCount} entries (
|
|
61
|
+
`Expired: ${status.expiredCount} entries pending prune (run \`context-vault prune\` to remove now)`,
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -93,6 +93,25 @@ export function handler(_args, ctx) {
|
|
|
93
93
|
lines.push(`Auto-reindex will fix this on next search or save.`);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
if (status.staleKnowledge?.length > 0) {
|
|
97
|
+
lines.push(``);
|
|
98
|
+
lines.push(`### ⚠ Potentially Stale Knowledge`);
|
|
99
|
+
lines.push(
|
|
100
|
+
`Not updated within kind staleness window (pattern: 180d, decision: 365d, reference: 90d):`,
|
|
101
|
+
);
|
|
102
|
+
for (const entry of status.staleKnowledge) {
|
|
103
|
+
const lastUpdated = entry.last_updated
|
|
104
|
+
? entry.last_updated.split("T")[0]
|
|
105
|
+
: "unknown";
|
|
106
|
+
lines.push(
|
|
107
|
+
`- "${entry.title}" (${entry.kind}) — last updated ${lastUpdated}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
lines.push(
|
|
111
|
+
`Use save_context to refresh or add expires_at to retire stale entries.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
// Error log
|
|
97
116
|
const logPath = errorLogPath(config.dataDir);
|
|
98
117
|
const logCount = errorLogCount(config.dataDir);
|
|
@@ -120,6 +139,21 @@ export function handler(_args, ctx) {
|
|
|
120
139
|
}
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
// Growth warnings
|
|
143
|
+
const growth = computeGrowthWarnings(status, config.thresholds);
|
|
144
|
+
if (growth.hasWarnings) {
|
|
145
|
+
lines.push("", "### ⚠ Vault Growth Warning");
|
|
146
|
+
for (const w of growth.warnings) {
|
|
147
|
+
lines.push(` ${w.message}`);
|
|
148
|
+
}
|
|
149
|
+
if (growth.actions.length) {
|
|
150
|
+
lines.push("", "Suggested growth actions:");
|
|
151
|
+
for (const a of growth.actions) {
|
|
152
|
+
lines.push(` • ${a}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
123
157
|
// Suggested actions
|
|
124
158
|
const actions = [];
|
|
125
159
|
if (status.stalePaths)
|
|
@@ -42,6 +42,12 @@ export const inputSchema = {
|
|
|
42
42
|
.optional()
|
|
43
43
|
.describe("ISO date, return entries created before this"),
|
|
44
44
|
limit: z.number().optional().describe("Max results to return (default 10)"),
|
|
45
|
+
include_superseded: z
|
|
46
|
+
.boolean()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe(
|
|
49
|
+
"If true, include entries that have been superseded by newer ones. Default: false.",
|
|
50
|
+
),
|
|
45
51
|
};
|
|
46
52
|
|
|
47
53
|
/**
|
|
@@ -50,7 +56,17 @@ export const inputSchema = {
|
|
|
50
56
|
* @param {import('../types.js').ToolShared} shared
|
|
51
57
|
*/
|
|
52
58
|
export async function handler(
|
|
53
|
-
{
|
|
59
|
+
{
|
|
60
|
+
query,
|
|
61
|
+
kind,
|
|
62
|
+
category,
|
|
63
|
+
identity_key,
|
|
64
|
+
tags,
|
|
65
|
+
since,
|
|
66
|
+
until,
|
|
67
|
+
limit,
|
|
68
|
+
include_superseded,
|
|
69
|
+
},
|
|
54
70
|
ctx,
|
|
55
71
|
{ ensureIndexed, reindexFailed },
|
|
56
72
|
) {
|
|
@@ -126,6 +142,7 @@ export async function handler(
|
|
|
126
142
|
limit: fetchLimit,
|
|
127
143
|
decayDays: config.eventDecayDays || 30,
|
|
128
144
|
userIdFilter: userId,
|
|
145
|
+
includeSuperseeded: include_superseded ?? false,
|
|
129
146
|
});
|
|
130
147
|
|
|
131
148
|
// Post-filter by tags if provided, then apply requested limit
|
|
@@ -238,8 +255,12 @@ export async function handler(
|
|
|
238
255
|
lines.push(
|
|
239
256
|
`### [${i + 1}/${filtered.length}] ${r.title || "(untitled)"} [${r.kind}/${r.category}]`,
|
|
240
257
|
);
|
|
258
|
+
const dateStr =
|
|
259
|
+
r.updated_at && r.updated_at !== r.created_at
|
|
260
|
+
? `${r.created_at} (updated ${r.updated_at})`
|
|
261
|
+
: r.created_at || "";
|
|
241
262
|
lines.push(
|
|
242
|
-
`${r.score.toFixed(3)} · ${tagStr} · ${relPath} · id: \`${r.id}\``,
|
|
263
|
+
`${r.score.toFixed(3)} · ${tagStr} · ${relPath} · ${dateStr} · id: \`${r.id}\``,
|
|
243
264
|
);
|
|
244
265
|
lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
|
|
245
266
|
lines.push("");
|
|
@@ -65,17 +65,6 @@ export async function handler(
|
|
|
65
65
|
|
|
66
66
|
await ensureIndexed();
|
|
67
67
|
|
|
68
|
-
// Hosted tier limit enforcement
|
|
69
|
-
if (ctx.checkLimits) {
|
|
70
|
-
const usage = ctx.checkLimits();
|
|
71
|
-
if (usage.entryCount >= usage.maxEntries) {
|
|
72
|
-
return err(
|
|
73
|
-
`Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`,
|
|
74
|
-
"LIMIT_EXCEEDED",
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
68
|
try {
|
|
80
69
|
const { ingestUrl } = await import("../../capture/ingest-url.js");
|
|
81
70
|
const entryData = await ingestUrl(targetUrl, { kind, tags });
|
|
@@ -6,7 +6,7 @@ import { ok } from "../helpers.js";
|
|
|
6
6
|
export const name = "list_context";
|
|
7
7
|
|
|
8
8
|
export const description =
|
|
9
|
-
"Browse vault entries without a search query. Returns id, title, kind, category, tags, created_at. Use get_context with a query for semantic search. Use this to browse by tags or find recent entries.";
|
|
9
|
+
"Browse vault entries without a search query. Returns id, title, kind, category, tags, created_at, updated_at. Use get_context with a query for semantic search. Use this to browse by tags or find recent entries.";
|
|
10
10
|
|
|
11
11
|
export const inputSchema = {
|
|
12
12
|
kind: z
|
|
@@ -101,7 +101,7 @@ export async function handler(
|
|
|
101
101
|
params.push(fetchLimit, effectiveOffset);
|
|
102
102
|
const rows = ctx.db
|
|
103
103
|
.prepare(
|
|
104
|
-
`SELECT id, title, kind, category, tags, created_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
104
|
+
`SELECT id, title, kind, category, tags, created_at, updated_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
105
105
|
)
|
|
106
106
|
.all(...params);
|
|
107
107
|
|
|
@@ -140,8 +140,12 @@ export async function handler(
|
|
|
140
140
|
for (const r of filtered) {
|
|
141
141
|
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
142
142
|
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
143
|
+
const dateStr =
|
|
144
|
+
r.updated_at && r.updated_at !== r.created_at
|
|
145
|
+
? `${r.created_at} (updated ${r.updated_at})`
|
|
146
|
+
: r.created_at;
|
|
143
147
|
lines.push(
|
|
144
|
-
`- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${
|
|
148
|
+
`- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${dateStr} — \`${r.id}\``,
|
|
145
149
|
);
|
|
146
150
|
if (r.preview)
|
|
147
151
|
lines.push(
|
|
@@ -15,6 +15,63 @@ import {
|
|
|
15
15
|
MAX_IDENTITY_KEY_LENGTH,
|
|
16
16
|
} from "../../constants.js";
|
|
17
17
|
|
|
18
|
+
const DEFAULT_SIMILARITY_THRESHOLD = 0.85;
|
|
19
|
+
|
|
20
|
+
async function findSimilar(ctx, embedding, threshold, userId) {
|
|
21
|
+
try {
|
|
22
|
+
const vecCount = ctx.db
|
|
23
|
+
.prepare("SELECT COUNT(*) as c FROM vault_vec")
|
|
24
|
+
.get().c;
|
|
25
|
+
if (vecCount === 0) return [];
|
|
26
|
+
|
|
27
|
+
const vecRows = ctx.db
|
|
28
|
+
.prepare(
|
|
29
|
+
`SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT ?`,
|
|
30
|
+
)
|
|
31
|
+
.all(embedding, 10);
|
|
32
|
+
|
|
33
|
+
if (!vecRows.length) return [];
|
|
34
|
+
|
|
35
|
+
const rowids = vecRows.map((vr) => vr.rowid);
|
|
36
|
+
const placeholders = rowids.map(() => "?").join(",");
|
|
37
|
+
const hydrated = ctx.db
|
|
38
|
+
.prepare(
|
|
39
|
+
`SELECT rowid, id, title, category, user_id FROM vault WHERE rowid IN (${placeholders})`,
|
|
40
|
+
)
|
|
41
|
+
.all(...rowids);
|
|
42
|
+
|
|
43
|
+
const byRowid = new Map();
|
|
44
|
+
for (const row of hydrated) byRowid.set(row.rowid, row);
|
|
45
|
+
|
|
46
|
+
const results = [];
|
|
47
|
+
for (const vr of vecRows) {
|
|
48
|
+
const similarity = Math.max(0, 1 - vr.distance / 2);
|
|
49
|
+
if (similarity < threshold) continue;
|
|
50
|
+
const row = byRowid.get(vr.rowid);
|
|
51
|
+
if (!row) continue;
|
|
52
|
+
if (userId !== undefined && row.user_id !== userId) continue;
|
|
53
|
+
if (row.category === "entity") continue;
|
|
54
|
+
results.push({ id: row.id, title: row.title, score: similarity });
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatSimilarWarning(similar) {
|
|
63
|
+
const lines = ["", "⚠ Similar entries already exist:"];
|
|
64
|
+
for (const e of similar) {
|
|
65
|
+
const score = e.score.toFixed(2);
|
|
66
|
+
const title = e.title ? `"${e.title}"` : "(no title)";
|
|
67
|
+
lines.push(` - ${title} (${score}) — id: ${e.id}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push(
|
|
70
|
+
" Consider using `id: <existing>` in save_context to update instead.",
|
|
71
|
+
);
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
18
75
|
/**
|
|
19
76
|
* Validate input fields for save_context. Returns an error response or null.
|
|
20
77
|
*/
|
|
@@ -150,6 +207,26 @@ export const inputSchema = {
|
|
|
150
207
|
"Required for entity kinds (contact, project, tool, source). The unique identifier for this entity.",
|
|
151
208
|
),
|
|
152
209
|
expires_at: z.string().optional().describe("ISO date for TTL expiry"),
|
|
210
|
+
supersedes: z
|
|
211
|
+
.array(z.string())
|
|
212
|
+
.optional()
|
|
213
|
+
.describe(
|
|
214
|
+
"Array of entry IDs that this entry supersedes/replaces. Those entries will be marked with superseded_by pointing to this new entry and excluded from future search results by default.",
|
|
215
|
+
),
|
|
216
|
+
dry_run: z
|
|
217
|
+
.boolean()
|
|
218
|
+
.optional()
|
|
219
|
+
.describe(
|
|
220
|
+
"If true, check for similar entries without saving. Returns similarity results without creating a new entry. Only applies to knowledge and event categories.",
|
|
221
|
+
),
|
|
222
|
+
similarity_threshold: z
|
|
223
|
+
.number()
|
|
224
|
+
.min(0)
|
|
225
|
+
.max(1)
|
|
226
|
+
.optional()
|
|
227
|
+
.describe(
|
|
228
|
+
"Cosine similarity threshold for duplicate detection (0–1, default 0.85). Entries above this score are flagged as similar. Only applies to knowledge and event categories.",
|
|
229
|
+
),
|
|
153
230
|
};
|
|
154
231
|
|
|
155
232
|
/**
|
|
@@ -169,6 +246,9 @@ export async function handler(
|
|
|
169
246
|
source,
|
|
170
247
|
identity_key,
|
|
171
248
|
expires_at,
|
|
249
|
+
supersedes,
|
|
250
|
+
dry_run,
|
|
251
|
+
similarity_threshold,
|
|
172
252
|
},
|
|
173
253
|
ctx,
|
|
174
254
|
{ ensureIndexed },
|
|
@@ -231,6 +311,7 @@ export async function handler(
|
|
|
231
311
|
meta,
|
|
232
312
|
source,
|
|
233
313
|
expires_at,
|
|
314
|
+
supersedes,
|
|
234
315
|
});
|
|
235
316
|
await indexEntry(ctx, entry);
|
|
236
317
|
const relPath = entry.filePath
|
|
@@ -261,25 +342,45 @@ export async function handler(
|
|
|
261
342
|
);
|
|
262
343
|
}
|
|
263
344
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
345
|
+
await ensureIndexed();
|
|
346
|
+
|
|
347
|
+
// ── Similarity check (knowledge + event only) ────────────────────────────
|
|
348
|
+
const category = categoryFor(normalizedKind);
|
|
349
|
+
let similarEntries = [];
|
|
350
|
+
|
|
351
|
+
if (category === "knowledge" || category === "event") {
|
|
352
|
+
const threshold = similarity_threshold ?? DEFAULT_SIMILARITY_THRESHOLD;
|
|
353
|
+
const embeddingText = [title, body].filter(Boolean).join(" ");
|
|
354
|
+
const queryEmbedding = await ctx.embed(embeddingText);
|
|
355
|
+
if (queryEmbedding) {
|
|
356
|
+
similarEntries = await findSimilar(
|
|
357
|
+
ctx,
|
|
358
|
+
queryEmbedding,
|
|
359
|
+
threshold,
|
|
360
|
+
userId,
|
|
271
361
|
);
|
|
272
362
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (dry_run) {
|
|
366
|
+
const parts = ["(dry run — nothing saved)"];
|
|
367
|
+
if (similarEntries.length) {
|
|
368
|
+
parts.push("", "⚠ Similar entries already exist:");
|
|
369
|
+
for (const e of similarEntries) {
|
|
370
|
+
const score = e.score.toFixed(2);
|
|
371
|
+
const titleDisplay = e.title ? `"${e.title}"` : "(no title)";
|
|
372
|
+
parts.push(` - ${titleDisplay} (${score}) — id: ${e.id}`);
|
|
373
|
+
}
|
|
374
|
+
parts.push(
|
|
375
|
+
"",
|
|
376
|
+
"Use save_context with `id: <existing>` to update one, or omit `dry_run` to save as new.",
|
|
277
377
|
);
|
|
378
|
+
} else {
|
|
379
|
+
parts.push("", "No similar entries found. Safe to save.");
|
|
278
380
|
}
|
|
381
|
+
return ok(parts.join("\n"));
|
|
279
382
|
}
|
|
280
383
|
|
|
281
|
-
await ensureIndexed();
|
|
282
|
-
|
|
283
384
|
const mergedMeta = { ...(meta || {}) };
|
|
284
385
|
if (folder) mergedMeta.folder = folder;
|
|
285
386
|
const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
|
|
@@ -294,6 +395,7 @@ export async function handler(
|
|
|
294
395
|
folder,
|
|
295
396
|
identity_key,
|
|
296
397
|
expires_at,
|
|
398
|
+
supersedes,
|
|
297
399
|
userId,
|
|
298
400
|
});
|
|
299
401
|
const relPath = entry.filePath
|
|
@@ -303,5 +405,28 @@ export async function handler(
|
|
|
303
405
|
if (title) parts.push(` title: ${title}`);
|
|
304
406
|
if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
|
|
305
407
|
parts.push("", "_Use this id to update or delete later._");
|
|
408
|
+
if (similarEntries.length) {
|
|
409
|
+
parts.push(formatSimilarWarning(similarEntries));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const criticalLimit = config.thresholds?.totalEntries?.critical;
|
|
413
|
+
if (criticalLimit != null) {
|
|
414
|
+
try {
|
|
415
|
+
const countRow = ctx.db
|
|
416
|
+
.prepare(
|
|
417
|
+
userId !== undefined
|
|
418
|
+
? "SELECT COUNT(*) as c FROM vault WHERE user_id = ?"
|
|
419
|
+
: "SELECT COUNT(*) as c FROM vault",
|
|
420
|
+
)
|
|
421
|
+
.get(...(userId !== undefined ? [userId] : []));
|
|
422
|
+
if (countRow.c >= criticalLimit) {
|
|
423
|
+
parts.push(
|
|
424
|
+
``,
|
|
425
|
+
`ℹ Vault has ${countRow.c.toLocaleString()} entries. Consider running \`context-vault reindex\` or reviewing old entries.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
} catch {}
|
|
429
|
+
}
|
|
430
|
+
|
|
306
431
|
return ok(parts.join("\n"));
|
|
307
432
|
}
|
package/src/server/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { reindex } from "../index/index.js";
|
|
2
2
|
import { captureAndIndex } from "../capture/index.js";
|
|
3
3
|
import { err } from "./helpers.js";
|
|
4
|
+
import { sendTelemetryEvent } from "../core/telemetry.js";
|
|
4
5
|
import pkg from "../../package.json" with { type: "json" };
|
|
5
6
|
|
|
6
7
|
import * as getContext from "./tools/get-context.js";
|
|
@@ -57,6 +58,12 @@ export function registerTools(server, ctx) {
|
|
|
57
58
|
timestamp: Date.now(),
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
sendTelemetryEvent(ctx.config, {
|
|
62
|
+
event: "tool_error",
|
|
63
|
+
code: "TIMEOUT",
|
|
64
|
+
tool: toolName,
|
|
65
|
+
cv_version: pkg.version,
|
|
66
|
+
});
|
|
60
67
|
return err(
|
|
61
68
|
"Tool timed out after 60s. Try a simpler query or run `context-vault reindex` first.",
|
|
62
69
|
"TIMEOUT",
|
|
@@ -70,6 +77,12 @@ export function registerTools(server, ctx) {
|
|
|
70
77
|
timestamp: Date.now(),
|
|
71
78
|
};
|
|
72
79
|
}
|
|
80
|
+
sendTelemetryEvent(ctx.config, {
|
|
81
|
+
event: "tool_error",
|
|
82
|
+
code: "UNKNOWN",
|
|
83
|
+
tool: toolName,
|
|
84
|
+
cv_version: pkg.version,
|
|
85
|
+
});
|
|
73
86
|
try {
|
|
74
87
|
await captureAndIndex(ctx, {
|
|
75
88
|
kind: "feedback",
|