@context-vault/core 2.17.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 +2 -3
- package/src/capture/file-ops.js +2 -0
- package/src/capture/index.js +14 -0
- package/src/core/categories.js +1 -0
- package/src/core/files.js +6 -29
- package/src/core/frontmatter.js +1 -0
- package/src/core/linking.js +161 -0
- package/src/core/migrate-dirs.js +196 -0
- package/src/core/temporal.js +146 -0
- package/src/index/db.js +178 -8
- package/src/index/index.js +89 -28
- package/src/index.js +5 -0
- package/src/retrieve/index.js +9 -136
- package/src/server/tools/create-snapshot.js +37 -68
- package/src/server/tools/get-context.js +108 -21
- package/src/server/tools/save-context.js +29 -6
- package/src/server/tools.js +0 -2
- package/src/server/tools/submit-feedback.js +0 -55
|
@@ -5,14 +5,13 @@ import { normalizeKind } from "../../core/files.js";
|
|
|
5
5
|
import { ok, err, ensureVaultExists } from "../helpers.js";
|
|
6
6
|
|
|
7
7
|
const NOISE_KINDS = new Set(["prompt-history", "task-notification"]);
|
|
8
|
-
const
|
|
9
|
-
const MAX_ENTRIES_FOR_SYNTHESIS = 40;
|
|
8
|
+
const MAX_ENTRIES_FOR_GATHER = 40;
|
|
10
9
|
const MAX_BODY_PER_ENTRY = 600;
|
|
11
10
|
|
|
12
11
|
export const name = "create_snapshot";
|
|
13
12
|
|
|
14
13
|
export const description =
|
|
15
|
-
"Pull all relevant vault entries matching a topic,
|
|
14
|
+
"Pull all relevant vault entries matching a topic, deduplicate, and save them as a structured context brief (kind: 'brief'). Entries are formatted as markdown — no external API or LLM call required. The calling agent can synthesize the gathered content directly. Retrieve with: get_context(kind: 'brief', identity_key: '<key>').";
|
|
16
15
|
|
|
17
16
|
export const inputSchema = {
|
|
18
17
|
topic: z.string().describe("The topic or project name to snapshot"),
|
|
@@ -38,62 +37,42 @@ export const inputSchema = {
|
|
|
38
37
|
),
|
|
39
38
|
};
|
|
40
39
|
|
|
41
|
-
function
|
|
42
|
-
const
|
|
40
|
+
function formatGatheredEntries(topic, entries) {
|
|
41
|
+
const header = [
|
|
42
|
+
`# ${topic} — Context Brief`,
|
|
43
|
+
"",
|
|
44
|
+
`*Gathered from ${entries.length} vault ${entries.length === 1 ? "entry" : "entries"}. Synthesize the content below to extract key decisions, patterns, and constraints.*`,
|
|
45
|
+
"",
|
|
46
|
+
"---",
|
|
47
|
+
"",
|
|
48
|
+
].join("\n");
|
|
49
|
+
|
|
50
|
+
const body = entries
|
|
43
51
|
.map((e, i) => {
|
|
44
52
|
const tags = e.tags ? JSON.parse(e.tags) : [];
|
|
45
53
|
const tagStr = tags.length ? tags.join(", ") : "none";
|
|
46
|
-
const
|
|
54
|
+
const updated = e.updated_at || e.created_at || "unknown";
|
|
55
|
+
const bodyText = e.body
|
|
47
56
|
? e.body.slice(0, MAX_BODY_PER_ENTRY) +
|
|
48
57
|
(e.body.length > MAX_BODY_PER_ENTRY ? "…" : "")
|
|
49
58
|
: "(no body)";
|
|
59
|
+
const title = e.title || `Entry ${i + 1}`;
|
|
50
60
|
return [
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
`## ${i + 1}. [${e.kind}] ${title}`,
|
|
62
|
+
"",
|
|
63
|
+
`**Tags:** ${tagStr}`,
|
|
64
|
+
`**Updated:** ${updated}`,
|
|
65
|
+
`**ID:** \`${e.id}\``,
|
|
66
|
+
"",
|
|
67
|
+
bodyText,
|
|
68
|
+
"",
|
|
69
|
+
"---",
|
|
70
|
+
"",
|
|
55
71
|
].join("\n");
|
|
56
72
|
})
|
|
57
|
-
.join("
|
|
58
|
-
|
|
59
|
-
return `You are a knowledge synthesis assistant. Given the following vault entries about "${topic}", produce a structured context brief.
|
|
60
|
-
|
|
61
|
-
Deduplicate overlapping information, resolve any contradictions (note them in Audit Notes), and organise the content into the sections below. Keep each section concise and actionable. Omit sections that have no relevant content.
|
|
62
|
-
|
|
63
|
-
Output ONLY the markdown document — no preamble, no explanation.
|
|
64
|
-
|
|
65
|
-
Required format:
|
|
66
|
-
# ${topic} — Context Brief
|
|
67
|
-
## Status
|
|
68
|
-
(current state of the topic)
|
|
69
|
-
## Key Decisions
|
|
70
|
-
(architectural or strategic decisions made)
|
|
71
|
-
## Patterns & Conventions
|
|
72
|
-
(recurring patterns, coding conventions, standards)
|
|
73
|
-
## Active Constraints
|
|
74
|
-
(known limitations, hard requirements, deadlines)
|
|
75
|
-
## Open Questions
|
|
76
|
-
(unresolved questions or areas needing investigation)
|
|
77
|
-
## Audit Notes
|
|
78
|
-
(contradictions detected, stale entries flagged with their ids)
|
|
73
|
+
.join("");
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
VAULT ENTRIES:
|
|
82
|
-
|
|
83
|
-
${entriesBlock}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function callLlm(prompt) {
|
|
87
|
-
const { Anthropic } = await import("@anthropic-ai/sdk");
|
|
88
|
-
const client = new Anthropic();
|
|
89
|
-
const message = await client.messages.create({
|
|
90
|
-
model: SYNTHESIS_MODEL,
|
|
91
|
-
max_tokens: 2048,
|
|
92
|
-
messages: [{ role: "user", content: prompt }],
|
|
93
|
-
});
|
|
94
|
-
const block = message.content.find((b) => b.type === "text");
|
|
95
|
-
if (!block) throw new Error("LLM returned no text content");
|
|
96
|
-
return block.text;
|
|
75
|
+
return header + body;
|
|
97
76
|
}
|
|
98
77
|
|
|
99
78
|
function slugifyTopic(topic) {
|
|
@@ -122,7 +101,6 @@ export async function handler(
|
|
|
122
101
|
await ensureIndexed();
|
|
123
102
|
|
|
124
103
|
const normalizedKinds = kinds?.map(normalizeKind) ?? [];
|
|
125
|
-
// Expand buckets to bucket: prefixed tags and merge with explicit tags
|
|
126
104
|
const bucketTags = buckets?.length ? buckets.map((b) => `bucket:${b}`) : [];
|
|
127
105
|
const effectiveTags = [...(tags ?? []), ...bucketTags];
|
|
128
106
|
|
|
@@ -132,7 +110,7 @@ export async function handler(
|
|
|
132
110
|
for (const kindFilter of normalizedKinds) {
|
|
133
111
|
const rows = await hybridSearch(ctx, topic, {
|
|
134
112
|
kindFilter,
|
|
135
|
-
limit: Math.ceil(
|
|
113
|
+
limit: Math.ceil(MAX_ENTRIES_FOR_GATHER / normalizedKinds.length),
|
|
136
114
|
userIdFilter: userId,
|
|
137
115
|
includeSuperseeded: false,
|
|
138
116
|
});
|
|
@@ -146,7 +124,7 @@ export async function handler(
|
|
|
146
124
|
});
|
|
147
125
|
} else {
|
|
148
126
|
candidates = await hybridSearch(ctx, topic, {
|
|
149
|
-
limit:
|
|
127
|
+
limit: MAX_ENTRIES_FOR_GATHER,
|
|
150
128
|
userIdFilter: userId,
|
|
151
129
|
includeSuperseeded: false,
|
|
152
130
|
});
|
|
@@ -163,25 +141,16 @@ export async function handler(
|
|
|
163
141
|
.filter((r) => NOISE_KINDS.has(r.kind))
|
|
164
142
|
.map((r) => r.id);
|
|
165
143
|
|
|
166
|
-
const
|
|
144
|
+
const gatherEntries = candidates.filter((r) => !NOISE_KINDS.has(r.kind));
|
|
167
145
|
|
|
168
|
-
if (
|
|
146
|
+
if (gatherEntries.length === 0) {
|
|
169
147
|
return err(
|
|
170
|
-
`No entries found for topic "${topic}"
|
|
148
|
+
`No entries found for topic "${topic}". Try a broader topic or different tags.`,
|
|
171
149
|
"NO_ENTRIES",
|
|
172
150
|
);
|
|
173
151
|
}
|
|
174
152
|
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
const prompt = buildSynthesisPrompt(topic, synthesisEntries);
|
|
178
|
-
briefBody = await callLlm(prompt);
|
|
179
|
-
} catch (e) {
|
|
180
|
-
return err(
|
|
181
|
-
`LLM synthesis failed: ${e.message}. Ensure ANTHROPIC_API_KEY is set.`,
|
|
182
|
-
"LLM_ERROR",
|
|
183
|
-
);
|
|
184
|
-
}
|
|
153
|
+
const briefBody = formatGatheredEntries(topic, gatherEntries);
|
|
185
154
|
|
|
186
155
|
const effectiveIdentityKey =
|
|
187
156
|
identity_key ?? `snapshot-${slugifyTopic(topic)}`;
|
|
@@ -205,9 +174,9 @@ export async function handler(
|
|
|
205
174
|
userId,
|
|
206
175
|
meta: {
|
|
207
176
|
topic,
|
|
208
|
-
entry_count:
|
|
177
|
+
entry_count: gatherEntries.length,
|
|
209
178
|
noise_superseded: noiseIds.length,
|
|
210
|
-
synthesized_from:
|
|
179
|
+
synthesized_from: gatherEntries.map((e) => e.id),
|
|
211
180
|
},
|
|
212
181
|
});
|
|
213
182
|
|
|
@@ -215,7 +184,7 @@ export async function handler(
|
|
|
215
184
|
`✓ Snapshot created → id: ${entry.id}`,
|
|
216
185
|
` title: ${entry.title}`,
|
|
217
186
|
` identity_key: ${effectiveIdentityKey}`,
|
|
218
|
-
` synthesized from: ${
|
|
187
|
+
` synthesized from: ${gatherEntries.length} entries`,
|
|
219
188
|
noiseIds.length > 0
|
|
220
189
|
? ` noise superseded: ${noiseIds.length} entries`
|
|
221
190
|
: null,
|
|
@@ -5,6 +5,8 @@ import { resolve } from "node:path";
|
|
|
5
5
|
import { hybridSearch } from "../../retrieve/index.js";
|
|
6
6
|
import { categoryFor } from "../../core/categories.js";
|
|
7
7
|
import { normalizeKind } from "../../core/files.js";
|
|
8
|
+
import { resolveTemporalParams } from "../../core/temporal.js";
|
|
9
|
+
import { collectLinkedEntries } from "../../core/linking.js";
|
|
8
10
|
import { ok, err } from "../helpers.js";
|
|
9
11
|
import { isEmbedAvailable } from "../../index/embed.js";
|
|
10
12
|
|
|
@@ -152,8 +154,9 @@ export function detectConsolidationHints(entries, db, userId, opts = {}) {
|
|
|
152
154
|
for (const tag of candidateTags) {
|
|
153
155
|
let vaultCount = 0;
|
|
154
156
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
// When userId is defined (hosted mode), scope to that user.
|
|
158
|
+
// When userId is undefined (local mode), no user scoping — column may not exist.
|
|
159
|
+
const userClause = userId !== undefined ? " AND user_id = ?" : "";
|
|
157
160
|
const countParams =
|
|
158
161
|
userId !== undefined ? [`%"${tag}"%`, userId] : [`%"${tag}"%`];
|
|
159
162
|
const countRow = db
|
|
@@ -170,8 +173,7 @@ export function detectConsolidationHints(entries, db, userId, opts = {}) {
|
|
|
170
173
|
|
|
171
174
|
let lastSnapshotAgeDays = null;
|
|
172
175
|
try {
|
|
173
|
-
const userClause =
|
|
174
|
-
userId !== undefined ? " AND user_id = ?" : " AND user_id IS NULL";
|
|
176
|
+
const userClause = userId !== undefined ? " AND user_id = ?" : "";
|
|
175
177
|
const params =
|
|
176
178
|
userId !== undefined ? [`%"${tag}"%`, userId] : [`%"${tag}"%`];
|
|
177
179
|
const recentBrief = db
|
|
@@ -280,11 +282,15 @@ export const inputSchema = {
|
|
|
280
282
|
since: z
|
|
281
283
|
.string()
|
|
282
284
|
.optional()
|
|
283
|
-
.describe(
|
|
285
|
+
.describe(
|
|
286
|
+
"Return entries created after this date. Accepts ISO date strings (e.g. '2025-01-01') or natural shortcuts: 'today', 'yesterday', 'this_week', 'this_month', 'last_3_days', 'last_2_weeks', 'last_1_month'. Spaces and underscores are interchangeable.",
|
|
287
|
+
),
|
|
284
288
|
until: z
|
|
285
289
|
.string()
|
|
286
290
|
.optional()
|
|
287
|
-
.describe(
|
|
291
|
+
.describe(
|
|
292
|
+
"Return entries created before this date. Accepts ISO date strings or the same natural shortcuts as `since`. When `since` is 'yesterday' and `until` is omitted, `until` is automatically set to the end of yesterday.",
|
|
293
|
+
),
|
|
288
294
|
limit: z.number().optional().describe("Max results to return (default 10)"),
|
|
289
295
|
include_superseded: z
|
|
290
296
|
.boolean()
|
|
@@ -320,7 +326,19 @@ export const inputSchema = {
|
|
|
320
326
|
.boolean()
|
|
321
327
|
.optional()
|
|
322
328
|
.describe(
|
|
323
|
-
"If true, include event category entries in semantic search results. Default: false — events are excluded from query-based search but remain accessible via category/tag filters.",
|
|
329
|
+
"If true, include event category entries in semantic search results. Default: false — events are excluded from query-based search but remain accessible via category/tag filters. Deprecated: prefer scope parameter.",
|
|
330
|
+
),
|
|
331
|
+
scope: z
|
|
332
|
+
.enum(["hot", "events", "all"])
|
|
333
|
+
.optional()
|
|
334
|
+
.describe(
|
|
335
|
+
"Index scope: 'hot' (default) — knowledge + entity entries only; 'events' — event entries only (cold index); 'all' — entire vault including events. Overrides include_events when set.",
|
|
336
|
+
),
|
|
337
|
+
follow_links: z
|
|
338
|
+
.boolean()
|
|
339
|
+
.optional()
|
|
340
|
+
.describe(
|
|
341
|
+
"If true, follow related_to links from result entries and include linked entries (forward links) and backlinks (entries that reference the results). Enables bidirectional graph traversal.",
|
|
324
342
|
),
|
|
325
343
|
};
|
|
326
344
|
|
|
@@ -346,6 +364,8 @@ export async function handler(
|
|
|
346
364
|
pivot_count,
|
|
347
365
|
include_ephemeral,
|
|
348
366
|
include_events,
|
|
367
|
+
scope,
|
|
368
|
+
follow_links,
|
|
349
369
|
},
|
|
350
370
|
ctx,
|
|
351
371
|
{ ensureIndexed, reindexFailed },
|
|
@@ -353,13 +373,37 @@ export async function handler(
|
|
|
353
373
|
const { config } = ctx;
|
|
354
374
|
const userId = ctx.userId !== undefined ? ctx.userId : undefined;
|
|
355
375
|
|
|
376
|
+
// Resolve natural-language temporal shortcuts → ISO date strings
|
|
377
|
+
const resolved = resolveTemporalParams({ since, until });
|
|
378
|
+
since = resolved.since;
|
|
379
|
+
until = resolved.until;
|
|
380
|
+
|
|
356
381
|
const hasQuery = query?.trim();
|
|
357
|
-
|
|
382
|
+
|
|
383
|
+
// Resolve effective scope — explicit scope param wins over include_events legacy flag.
|
|
384
|
+
// scope "hot" (default): knowledge + entity only — events excluded from search
|
|
385
|
+
// scope "events": force category filter to "event" (cold index query)
|
|
386
|
+
// scope "all": no category restriction — full vault
|
|
387
|
+
let effectiveScope = scope;
|
|
388
|
+
if (!effectiveScope) {
|
|
389
|
+
effectiveScope = include_events ? "all" : "hot";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Scope "events" forces category to "event" unless caller already set a narrower category
|
|
393
|
+
const scopedCategory =
|
|
394
|
+
!category && effectiveScope === "events" ? "event" : category;
|
|
395
|
+
const shouldExcludeEvents =
|
|
396
|
+
hasQuery && effectiveScope === "hot" && !scopedCategory;
|
|
358
397
|
// Expand buckets to bucket: prefixed tags and merge with explicit tags
|
|
359
398
|
const bucketTags = buckets?.length ? buckets.map((b) => `bucket:${b}`) : [];
|
|
360
399
|
const effectiveTags = [...(tags ?? []), ...bucketTags];
|
|
361
400
|
const hasFilters =
|
|
362
|
-
kind ||
|
|
401
|
+
kind ||
|
|
402
|
+
scopedCategory ||
|
|
403
|
+
effectiveTags.length ||
|
|
404
|
+
since ||
|
|
405
|
+
until ||
|
|
406
|
+
identity_key;
|
|
363
407
|
if (!hasQuery && !hasFilters)
|
|
364
408
|
return err(
|
|
365
409
|
"Required: query or at least one filter (kind, category, tags, since, until, identity_key)",
|
|
@@ -373,11 +417,16 @@ export async function handler(
|
|
|
373
417
|
if (identity_key) {
|
|
374
418
|
if (!kindFilter)
|
|
375
419
|
return err("identity_key requires kind to be specified", "INVALID_INPUT");
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
420
|
+
// Local mode: getByIdentityKey takes 2 params (no user_id).
|
|
421
|
+
// Hosted mode: 3 params — (kind, identity_key, userId).
|
|
422
|
+
const match =
|
|
423
|
+
ctx.stmts._mode === "local"
|
|
424
|
+
? ctx.stmts.getByIdentityKey.get(kindFilter, identity_key)
|
|
425
|
+
: ctx.stmts.getByIdentityKey.get(
|
|
426
|
+
kindFilter,
|
|
427
|
+
identity_key,
|
|
428
|
+
userId !== undefined ? userId : null,
|
|
429
|
+
);
|
|
381
430
|
if (match) {
|
|
382
431
|
const entryTags = match.tags ? JSON.parse(match.tags) : [];
|
|
383
432
|
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
@@ -398,7 +447,7 @@ export async function handler(
|
|
|
398
447
|
|
|
399
448
|
// Gap 2: Event default time-window
|
|
400
449
|
const effectiveCategory =
|
|
401
|
-
|
|
450
|
+
scopedCategory || (kindFilter ? categoryFor(kindFilter) : null);
|
|
402
451
|
let effectiveSince = since || null;
|
|
403
452
|
let effectiveUntil = until || null;
|
|
404
453
|
let autoWindowed = false;
|
|
@@ -420,7 +469,7 @@ export async function handler(
|
|
|
420
469
|
// Hybrid search mode
|
|
421
470
|
const sorted = await hybridSearch(ctx, query, {
|
|
422
471
|
kindFilter,
|
|
423
|
-
categoryFilter:
|
|
472
|
+
categoryFilter: scopedCategory || null,
|
|
424
473
|
excludeEvents: shouldExcludeEvents,
|
|
425
474
|
since: effectiveSince,
|
|
426
475
|
until: effectiveUntil,
|
|
@@ -451,9 +500,9 @@ export async function handler(
|
|
|
451
500
|
clauses.push("kind = ?");
|
|
452
501
|
params.push(kindFilter);
|
|
453
502
|
}
|
|
454
|
-
if (
|
|
503
|
+
if (scopedCategory) {
|
|
455
504
|
clauses.push("category = ?");
|
|
456
|
-
params.push(
|
|
505
|
+
params.push(scopedCategory);
|
|
457
506
|
}
|
|
458
507
|
if (effectiveSince) {
|
|
459
508
|
clauses.push("created_at >= ?");
|
|
@@ -632,6 +681,45 @@ export async function handler(
|
|
|
632
681
|
}
|
|
633
682
|
}
|
|
634
683
|
|
|
684
|
+
// Graph traversal: follow related_to links bidirectionally
|
|
685
|
+
if (follow_links) {
|
|
686
|
+
const { forward, backward } = collectLinkedEntries(
|
|
687
|
+
ctx.db,
|
|
688
|
+
filtered,
|
|
689
|
+
userId,
|
|
690
|
+
);
|
|
691
|
+
const allLinked = [...forward, ...backward];
|
|
692
|
+
const seen = new Set();
|
|
693
|
+
const uniqueLinked = allLinked.filter((e) => {
|
|
694
|
+
if (seen.has(e.id)) return false;
|
|
695
|
+
seen.add(e.id);
|
|
696
|
+
return true;
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
if (uniqueLinked.length > 0) {
|
|
700
|
+
lines.push(`## Linked Entries (${uniqueLinked.length} via related_to)\n`);
|
|
701
|
+
for (const r of uniqueLinked) {
|
|
702
|
+
const direction = forward.some((f) => f.id === r.id)
|
|
703
|
+
? "→ forward"
|
|
704
|
+
: "← backlink";
|
|
705
|
+
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
706
|
+
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
707
|
+
const relPath =
|
|
708
|
+
r.file_path && config.vaultDir
|
|
709
|
+
? r.file_path.replace(config.vaultDir + "/", "")
|
|
710
|
+
: r.file_path || "n/a";
|
|
711
|
+
lines.push(
|
|
712
|
+
`### ${r.title || "(untitled)"} [${r.kind}/${r.category}] ${direction}`,
|
|
713
|
+
);
|
|
714
|
+
lines.push(`${tagStr} · ${relPath} · id: \`${r.id}\``);
|
|
715
|
+
lines.push(r.body?.slice(0, 200) + (r.body?.length > 200 ? "..." : ""));
|
|
716
|
+
lines.push("");
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
lines.push(`## Linked Entries\n\nNo related entries found.\n`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
635
723
|
// Consolidation suggestion detection — lazy, opportunistic, vault-wide
|
|
636
724
|
const consolidationOpts = {
|
|
637
725
|
tagThreshold:
|
|
@@ -661,6 +749,7 @@ export async function handler(
|
|
|
661
749
|
|
|
662
750
|
const result = ok(lines.join("\n"));
|
|
663
751
|
const meta = {};
|
|
752
|
+
meta.scope = effectiveScope;
|
|
664
753
|
if (tokensBudget != null) {
|
|
665
754
|
meta.tokens_used = tokensUsed;
|
|
666
755
|
meta.tokens_budget = tokensBudget;
|
|
@@ -671,8 +760,6 @@ export async function handler(
|
|
|
671
760
|
if (consolidationSuggestions.length > 0) {
|
|
672
761
|
meta.consolidation_suggestions = consolidationSuggestions;
|
|
673
762
|
}
|
|
674
|
-
|
|
675
|
-
result._meta = meta;
|
|
676
|
-
}
|
|
763
|
+
result._meta = meta;
|
|
677
764
|
return result;
|
|
678
765
|
}
|
|
@@ -5,6 +5,7 @@ import { categoryFor, defaultTierFor } from "../../core/categories.js";
|
|
|
5
5
|
import { normalizeKind } from "../../core/files.js";
|
|
6
6
|
import { ok, err, ensureVaultExists, ensureValidKind } from "../helpers.js";
|
|
7
7
|
import { maybeShowFeedbackPrompt } from "../../core/telemetry.js";
|
|
8
|
+
import { validateRelatedTo } from "../../core/linking.js";
|
|
8
9
|
import {
|
|
9
10
|
MAX_BODY_LENGTH,
|
|
10
11
|
MAX_TITLE_LENGTH,
|
|
@@ -43,9 +44,15 @@ async function findSimilar(
|
|
|
43
44
|
|
|
44
45
|
const rowids = vecRows.map((vr) => vr.rowid);
|
|
45
46
|
const placeholders = rowids.map(() => "?").join(",");
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Local mode has no user_id column — omit it from the SELECT list.
|
|
48
|
+
const isLocal = ctx.stmts._mode === "local";
|
|
49
|
+
const columns = isLocal
|
|
50
|
+
? hydrate
|
|
51
|
+
? "rowid, id, title, body, kind, tags, category, updated_at"
|
|
52
|
+
: "rowid, id, title, category"
|
|
53
|
+
: hydrate
|
|
54
|
+
? "rowid, id, title, body, kind, tags, category, user_id, updated_at"
|
|
55
|
+
: "rowid, id, title, category, user_id";
|
|
49
56
|
const hydratedRows = ctx.db
|
|
50
57
|
.prepare(`SELECT ${columns} FROM vault WHERE rowid IN (${placeholders})`)
|
|
51
58
|
.all(...rowids);
|
|
@@ -59,7 +66,7 @@ async function findSimilar(
|
|
|
59
66
|
if (similarity < threshold) continue;
|
|
60
67
|
const row = byRowid.get(vr.rowid);
|
|
61
68
|
if (!row) continue;
|
|
62
|
-
if (userId !== undefined && row.user_id !== userId) continue;
|
|
69
|
+
if (!isLocal && userId !== undefined && row.user_id !== userId) continue;
|
|
63
70
|
if (row.category === "entity") continue;
|
|
64
71
|
const entry = { id: row.id, title: row.title, score: similarity };
|
|
65
72
|
if (hydrate) {
|
|
@@ -294,6 +301,12 @@ export const inputSchema = {
|
|
|
294
301
|
.describe(
|
|
295
302
|
"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.",
|
|
296
303
|
),
|
|
304
|
+
related_to: z
|
|
305
|
+
.array(z.string())
|
|
306
|
+
.optional()
|
|
307
|
+
.describe(
|
|
308
|
+
"Array of entry IDs this entry is related to. Enables bidirectional graph traversal — use get_context with follow_links:true to retrieve linked entries.",
|
|
309
|
+
),
|
|
297
310
|
source_files: z
|
|
298
311
|
.array(
|
|
299
312
|
z.object({
|
|
@@ -359,6 +372,7 @@ export async function handler(
|
|
|
359
372
|
identity_key,
|
|
360
373
|
expires_at,
|
|
361
374
|
supersedes,
|
|
375
|
+
related_to,
|
|
362
376
|
source_files,
|
|
363
377
|
dry_run,
|
|
364
378
|
similarity_threshold,
|
|
@@ -375,6 +389,9 @@ export async function handler(
|
|
|
375
389
|
const vaultErr = ensureVaultExists(config);
|
|
376
390
|
if (vaultErr) return vaultErr;
|
|
377
391
|
|
|
392
|
+
const relatedToErr = validateRelatedTo(related_to);
|
|
393
|
+
if (relatedToErr) return err(relatedToErr, "INVALID_INPUT");
|
|
394
|
+
|
|
378
395
|
const inputErr = validateSaveInput({
|
|
379
396
|
kind,
|
|
380
397
|
title,
|
|
@@ -428,9 +445,15 @@ export async function handler(
|
|
|
428
445
|
source,
|
|
429
446
|
expires_at,
|
|
430
447
|
supersedes,
|
|
448
|
+
related_to,
|
|
431
449
|
source_files,
|
|
432
450
|
});
|
|
433
451
|
await indexEntry(ctx, entry);
|
|
452
|
+
if (entry.related_to?.length && ctx.stmts.updateRelatedTo) {
|
|
453
|
+
ctx.stmts.updateRelatedTo.run(JSON.stringify(entry.related_to), entry.id);
|
|
454
|
+
} else if (entry.related_to === null && ctx.stmts.updateRelatedTo) {
|
|
455
|
+
ctx.stmts.updateRelatedTo.run(null, entry.id);
|
|
456
|
+
}
|
|
434
457
|
const relPath = entry.filePath
|
|
435
458
|
? entry.filePath.replace(config.vaultDir + "/", "")
|
|
436
459
|
: entry.filePath;
|
|
@@ -531,6 +554,7 @@ export async function handler(
|
|
|
531
554
|
identity_key,
|
|
532
555
|
expires_at,
|
|
533
556
|
supersedes,
|
|
557
|
+
related_to,
|
|
534
558
|
source_files,
|
|
535
559
|
userId,
|
|
536
560
|
tier: effectiveTier,
|
|
@@ -562,8 +586,7 @@ export async function handler(
|
|
|
562
586
|
);
|
|
563
587
|
for (const bt of bucketTags) {
|
|
564
588
|
const bucketUserClause = userId !== undefined ? "AND user_id = ?" : "";
|
|
565
|
-
const bucketParams =
|
|
566
|
-
userId !== undefined ? [bt, userId] : [bt];
|
|
589
|
+
const bucketParams = userId !== undefined ? [bt, userId] : [bt];
|
|
567
590
|
const exists = ctx.db
|
|
568
591
|
.prepare(
|
|
569
592
|
`SELECT 1 FROM vault WHERE kind = 'bucket' AND identity_key = ? ${bucketUserClause} LIMIT 1`,
|
package/src/server/tools.js
CHANGED
|
@@ -8,7 +8,6 @@ import * as getContext from "./tools/get-context.js";
|
|
|
8
8
|
import * as saveContext from "./tools/save-context.js";
|
|
9
9
|
import * as listContext from "./tools/list-context.js";
|
|
10
10
|
import * as deleteContext from "./tools/delete-context.js";
|
|
11
|
-
import * as submitFeedback from "./tools/submit-feedback.js";
|
|
12
11
|
import * as ingestUrl from "./tools/ingest-url.js";
|
|
13
12
|
import * as contextStatus from "./tools/context-status.js";
|
|
14
13
|
import * as clearContext from "./tools/clear-context.js";
|
|
@@ -22,7 +21,6 @@ const toolModules = [
|
|
|
22
21
|
saveContext,
|
|
23
22
|
listContext,
|
|
24
23
|
deleteContext,
|
|
25
|
-
submitFeedback,
|
|
26
24
|
ingestUrl,
|
|
27
25
|
ingestProject,
|
|
28
26
|
contextStatus,
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { captureAndIndex } from "../../capture/index.js";
|
|
3
|
-
import { ok, ensureVaultExists } from "../helpers.js";
|
|
4
|
-
|
|
5
|
-
export const name = "submit_feedback";
|
|
6
|
-
|
|
7
|
-
export const description =
|
|
8
|
-
"Report a bug, request a feature, or suggest an improvement. Feedback is stored in the vault and triaged by the development pipeline.";
|
|
9
|
-
|
|
10
|
-
export const inputSchema = {
|
|
11
|
-
type: z.enum(["bug", "feature", "improvement"]).describe("Type of feedback"),
|
|
12
|
-
title: z.string().describe("Short summary of the feedback"),
|
|
13
|
-
body: z.string().describe("Detailed description"),
|
|
14
|
-
severity: z
|
|
15
|
-
.enum(["low", "medium", "high"])
|
|
16
|
-
.optional()
|
|
17
|
-
.describe("Severity level (default: medium)"),
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* @param {object} args
|
|
22
|
-
* @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
|
|
23
|
-
* @param {import('../types.js').ToolShared} shared
|
|
24
|
-
*/
|
|
25
|
-
export async function handler(
|
|
26
|
-
{ type, title, body, severity },
|
|
27
|
-
ctx,
|
|
28
|
-
{ ensureIndexed },
|
|
29
|
-
) {
|
|
30
|
-
const { config } = ctx;
|
|
31
|
-
const userId = ctx.userId !== undefined ? ctx.userId : undefined;
|
|
32
|
-
|
|
33
|
-
const vaultErr = ensureVaultExists(config);
|
|
34
|
-
if (vaultErr) return vaultErr;
|
|
35
|
-
|
|
36
|
-
await ensureIndexed();
|
|
37
|
-
|
|
38
|
-
const effectiveSeverity = severity || "medium";
|
|
39
|
-
const entry = await captureAndIndex(ctx, {
|
|
40
|
-
kind: "feedback",
|
|
41
|
-
title,
|
|
42
|
-
body,
|
|
43
|
-
tags: [type, effectiveSeverity],
|
|
44
|
-
source: "submit_feedback",
|
|
45
|
-
meta: { feedback_type: type, severity: effectiveSeverity, status: "new" },
|
|
46
|
-
userId,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const relPath = entry.filePath
|
|
50
|
-
? entry.filePath.replace(config.vaultDir + "/", "")
|
|
51
|
-
: entry.filePath;
|
|
52
|
-
return ok(
|
|
53
|
-
`Feedback submitted: ${type} [${effectiveSeverity}] → ${relPath}\n id: ${entry.id}\n title: ${title}`,
|
|
54
|
-
);
|
|
55
|
-
}
|