@context-vault/core 2.8.3
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/LICENSE +21 -0
- package/package.json +52 -0
- package/src/capture/file-ops.js +93 -0
- package/src/capture/formatters.js +29 -0
- package/src/capture/import-pipeline.js +46 -0
- package/src/capture/importers.js +387 -0
- package/src/capture/index.js +199 -0
- package/src/capture/ingest-url.js +252 -0
- package/src/constants.js +8 -0
- package/src/core/categories.js +51 -0
- package/src/core/config.js +127 -0
- package/src/core/files.js +108 -0
- package/src/core/frontmatter.js +120 -0
- package/src/core/status.js +146 -0
- package/src/index/db.js +268 -0
- package/src/index/embed.js +101 -0
- package/src/index/index.js +451 -0
- package/src/index.js +62 -0
- package/src/retrieve/index.js +219 -0
- package/src/server/helpers.js +31 -0
- package/src/server/tools/context-status.js +104 -0
- package/src/server/tools/delete-context.js +53 -0
- package/src/server/tools/get-context.js +235 -0
- package/src/server/tools/ingest-url.js +99 -0
- package/src/server/tools/list-context.js +134 -0
- package/src/server/tools/save-context.js +297 -0
- package/src/server/tools/submit-feedback.js +55 -0
- package/src/server/tools.js +111 -0
- package/src/sync/sync.js +235 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { captureAndIndex } from "../../capture/index.js";
|
|
3
|
+
import { ok, err, ensureVaultExists } from "../helpers.js";
|
|
4
|
+
import {
|
|
5
|
+
MAX_KIND_LENGTH,
|
|
6
|
+
MAX_TAG_LENGTH,
|
|
7
|
+
MAX_TAGS_COUNT,
|
|
8
|
+
} from "../../constants.js";
|
|
9
|
+
|
|
10
|
+
const MAX_URL_LENGTH = 2048;
|
|
11
|
+
|
|
12
|
+
export const name = "ingest_url";
|
|
13
|
+
|
|
14
|
+
export const description =
|
|
15
|
+
"Fetch a URL, extract its readable content, and save it as a vault entry. Useful for saving articles, documentation, or web pages to your knowledge vault.";
|
|
16
|
+
|
|
17
|
+
export const inputSchema = {
|
|
18
|
+
url: z.string().describe("The URL to fetch and save"),
|
|
19
|
+
kind: z.string().optional().describe("Entry kind (default: reference)"),
|
|
20
|
+
tags: z.array(z.string()).optional().describe("Tags for the entry"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} args
|
|
25
|
+
* @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
|
|
26
|
+
* @param {import('../types.js').ToolShared} shared
|
|
27
|
+
*/
|
|
28
|
+
export async function handler(
|
|
29
|
+
{ url: targetUrl, kind, tags },
|
|
30
|
+
ctx,
|
|
31
|
+
{ ensureIndexed },
|
|
32
|
+
) {
|
|
33
|
+
const { config } = ctx;
|
|
34
|
+
const userId = ctx.userId !== undefined ? ctx.userId : undefined;
|
|
35
|
+
|
|
36
|
+
const vaultErr = ensureVaultExists(config);
|
|
37
|
+
if (vaultErr) return vaultErr;
|
|
38
|
+
|
|
39
|
+
if (!targetUrl?.trim())
|
|
40
|
+
return err("Required: url (non-empty string)", "INVALID_INPUT");
|
|
41
|
+
if (targetUrl.length > MAX_URL_LENGTH)
|
|
42
|
+
return err(`url must be under ${MAX_URL_LENGTH} chars`, "INVALID_INPUT");
|
|
43
|
+
if (kind !== undefined && kind !== null) {
|
|
44
|
+
if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
|
|
45
|
+
return err(
|
|
46
|
+
`kind must be a string, max ${MAX_KIND_LENGTH} chars`,
|
|
47
|
+
"INVALID_INPUT",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (tags !== undefined && tags !== null) {
|
|
52
|
+
if (!Array.isArray(tags))
|
|
53
|
+
return err("tags must be an array of strings", "INVALID_INPUT");
|
|
54
|
+
if (tags.length > MAX_TAGS_COUNT)
|
|
55
|
+
return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
|
|
56
|
+
for (const tag of tags) {
|
|
57
|
+
if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
|
|
58
|
+
return err(
|
|
59
|
+
`each tag must be a string, max ${MAX_TAG_LENGTH} chars`,
|
|
60
|
+
"INVALID_INPUT",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await ensureIndexed();
|
|
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
|
+
try {
|
|
80
|
+
const { ingestUrl } = await import("../../capture/ingest-url.js");
|
|
81
|
+
const entryData = await ingestUrl(targetUrl, { kind, tags });
|
|
82
|
+
const entry = await captureAndIndex(ctx, { ...entryData, userId });
|
|
83
|
+
const relPath = entry.filePath
|
|
84
|
+
? entry.filePath.replace(config.vaultDir + "/", "")
|
|
85
|
+
: entry.filePath;
|
|
86
|
+
const parts = [
|
|
87
|
+
`✓ Ingested URL → ${relPath}`,
|
|
88
|
+
` id: ${entry.id}`,
|
|
89
|
+
` title: ${entry.title || "(untitled)"}`,
|
|
90
|
+
` source: ${entry.source || targetUrl}`,
|
|
91
|
+
];
|
|
92
|
+
if (entry.tags?.length) parts.push(` tags: ${entry.tags.join(", ")}`);
|
|
93
|
+
parts.push(` body: ${entry.body?.length || 0} chars`);
|
|
94
|
+
parts.push("", "_Use this id to update or delete later._");
|
|
95
|
+
return ok(parts.join("\n"));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return err(`Failed to ingest URL: ${e.message}`, "INGEST_FAILED");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { normalizeKind } from "../../core/files.js";
|
|
3
|
+
import { ok } from "../helpers.js";
|
|
4
|
+
|
|
5
|
+
export const name = "list_context";
|
|
6
|
+
|
|
7
|
+
export const description =
|
|
8
|
+
"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
|
+
|
|
10
|
+
export const inputSchema = {
|
|
11
|
+
kind: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Filter by kind (e.g. 'insight', 'decision', 'pattern')"),
|
|
15
|
+
category: z
|
|
16
|
+
.enum(["knowledge", "entity", "event"])
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Filter by category"),
|
|
19
|
+
tags: z
|
|
20
|
+
.array(z.string())
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Filter by tags (entries must match at least one)"),
|
|
23
|
+
since: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("ISO date, return entries created after this"),
|
|
27
|
+
until: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("ISO date, return entries created before this"),
|
|
31
|
+
limit: z
|
|
32
|
+
.number()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Max results to return (default 20, max 100)"),
|
|
35
|
+
offset: z.number().optional().describe("Skip first N results for pagination"),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} args
|
|
40
|
+
* @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
|
|
41
|
+
* @param {import('../types.js').ToolShared} shared
|
|
42
|
+
*/
|
|
43
|
+
export async function handler(
|
|
44
|
+
{ kind, category, tags, since, until, limit, offset },
|
|
45
|
+
ctx,
|
|
46
|
+
{ ensureIndexed, reindexFailed },
|
|
47
|
+
) {
|
|
48
|
+
const { config } = ctx;
|
|
49
|
+
const userId = ctx.userId !== undefined ? ctx.userId : undefined;
|
|
50
|
+
|
|
51
|
+
await ensureIndexed();
|
|
52
|
+
|
|
53
|
+
const clauses = [];
|
|
54
|
+
const params = [];
|
|
55
|
+
|
|
56
|
+
if (userId !== undefined) {
|
|
57
|
+
clauses.push("user_id = ?");
|
|
58
|
+
params.push(userId);
|
|
59
|
+
}
|
|
60
|
+
if (kind) {
|
|
61
|
+
clauses.push("kind = ?");
|
|
62
|
+
params.push(normalizeKind(kind));
|
|
63
|
+
}
|
|
64
|
+
if (category) {
|
|
65
|
+
clauses.push("category = ?");
|
|
66
|
+
params.push(category);
|
|
67
|
+
}
|
|
68
|
+
if (since) {
|
|
69
|
+
clauses.push("created_at >= ?");
|
|
70
|
+
params.push(since);
|
|
71
|
+
}
|
|
72
|
+
if (until) {
|
|
73
|
+
clauses.push("created_at <= ?");
|
|
74
|
+
params.push(until);
|
|
75
|
+
}
|
|
76
|
+
clauses.push("(expires_at IS NULL OR expires_at > datetime('now'))");
|
|
77
|
+
|
|
78
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
79
|
+
const effectiveLimit = Math.min(limit || 20, 100);
|
|
80
|
+
const effectiveOffset = offset || 0;
|
|
81
|
+
// When tag-filtering, over-fetch to compensate for post-filter reduction
|
|
82
|
+
const fetchLimit = tags?.length ? effectiveLimit * 10 : effectiveLimit;
|
|
83
|
+
|
|
84
|
+
const countParams = [...params];
|
|
85
|
+
const total = ctx.db
|
|
86
|
+
.prepare(`SELECT COUNT(*) as c FROM vault ${where}`)
|
|
87
|
+
.get(...countParams).c;
|
|
88
|
+
|
|
89
|
+
params.push(fetchLimit, effectiveOffset);
|
|
90
|
+
const rows = ctx.db
|
|
91
|
+
.prepare(
|
|
92
|
+
`SELECT id, title, kind, category, tags, created_at, SUBSTR(body, 1, 120) as preview FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
93
|
+
)
|
|
94
|
+
.all(...params);
|
|
95
|
+
|
|
96
|
+
// Post-filter by tags if provided, then apply requested limit
|
|
97
|
+
const filtered = tags?.length
|
|
98
|
+
? rows
|
|
99
|
+
.filter((r) => {
|
|
100
|
+
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
101
|
+
return tags.some((t) => entryTags.includes(t));
|
|
102
|
+
})
|
|
103
|
+
.slice(0, effectiveLimit)
|
|
104
|
+
: rows;
|
|
105
|
+
|
|
106
|
+
if (!filtered.length)
|
|
107
|
+
return ok("No entries found matching the given filters.");
|
|
108
|
+
|
|
109
|
+
const lines = [];
|
|
110
|
+
if (reindexFailed)
|
|
111
|
+
lines.push(
|
|
112
|
+
`> **Warning:** Auto-reindex failed. Results may be stale. Run \`context-vault reindex\` to fix.\n`,
|
|
113
|
+
);
|
|
114
|
+
lines.push(`## Vault Entries (${filtered.length} shown, ${total} total)\n`);
|
|
115
|
+
for (const r of filtered) {
|
|
116
|
+
const entryTags = r.tags ? JSON.parse(r.tags) : [];
|
|
117
|
+
const tagStr = entryTags.length ? entryTags.join(", ") : "none";
|
|
118
|
+
lines.push(
|
|
119
|
+
`- **${r.title || "(untitled)"}** [${r.kind}/${r.category}] — ${tagStr} — ${r.created_at} — \`${r.id}\``,
|
|
120
|
+
);
|
|
121
|
+
if (r.preview)
|
|
122
|
+
lines.push(
|
|
123
|
+
` ${r.preview.replace(/\n+/g, " ").trim()}${r.preview.length >= 120 ? "…" : ""}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (effectiveOffset + effectiveLimit < total) {
|
|
128
|
+
lines.push(
|
|
129
|
+
`\n_Page ${Math.floor(effectiveOffset / effectiveLimit) + 1}. Use offset: ${effectiveOffset + effectiveLimit} for next page._`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return ok(lines.join("\n"));
|
|
134
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { captureAndIndex, updateEntryFile } from "../../capture/index.js";
|
|
3
|
+
import { indexEntry } from "../../index/index.js";
|
|
4
|
+
import { categoryFor } from "../../core/categories.js";
|
|
5
|
+
import { normalizeKind } from "../../core/files.js";
|
|
6
|
+
import { ok, err, ensureVaultExists, ensureValidKind } from "../helpers.js";
|
|
7
|
+
import {
|
|
8
|
+
MAX_BODY_LENGTH,
|
|
9
|
+
MAX_TITLE_LENGTH,
|
|
10
|
+
MAX_KIND_LENGTH,
|
|
11
|
+
MAX_TAG_LENGTH,
|
|
12
|
+
MAX_TAGS_COUNT,
|
|
13
|
+
MAX_META_LENGTH,
|
|
14
|
+
MAX_SOURCE_LENGTH,
|
|
15
|
+
MAX_IDENTITY_KEY_LENGTH,
|
|
16
|
+
} from "../../constants.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate input fields for save_context. Returns an error response or null.
|
|
20
|
+
*/
|
|
21
|
+
function validateSaveInput({
|
|
22
|
+
kind,
|
|
23
|
+
title,
|
|
24
|
+
body,
|
|
25
|
+
tags,
|
|
26
|
+
meta,
|
|
27
|
+
source,
|
|
28
|
+
identity_key,
|
|
29
|
+
}) {
|
|
30
|
+
if (kind !== undefined && kind !== null) {
|
|
31
|
+
if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
|
|
32
|
+
return err(
|
|
33
|
+
`kind must be a string, max ${MAX_KIND_LENGTH} chars`,
|
|
34
|
+
"INVALID_INPUT",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (body !== undefined && body !== null) {
|
|
39
|
+
if (typeof body !== "string" || body.length > MAX_BODY_LENGTH) {
|
|
40
|
+
return err(
|
|
41
|
+
`body must be a string, max ${MAX_BODY_LENGTH / 1024}KB`,
|
|
42
|
+
"INVALID_INPUT",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (title !== undefined && title !== null) {
|
|
47
|
+
if (typeof title !== "string" || title.length > MAX_TITLE_LENGTH) {
|
|
48
|
+
return err(
|
|
49
|
+
`title must be a string, max ${MAX_TITLE_LENGTH} chars`,
|
|
50
|
+
"INVALID_INPUT",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (tags !== undefined && tags !== null) {
|
|
55
|
+
if (!Array.isArray(tags))
|
|
56
|
+
return err("tags must be an array of strings", "INVALID_INPUT");
|
|
57
|
+
if (tags.length > MAX_TAGS_COUNT)
|
|
58
|
+
return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
|
|
59
|
+
for (const tag of tags) {
|
|
60
|
+
if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
|
|
61
|
+
return err(
|
|
62
|
+
`each tag must be a string, max ${MAX_TAG_LENGTH} chars`,
|
|
63
|
+
"INVALID_INPUT",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (meta !== undefined && meta !== null) {
|
|
69
|
+
const metaStr = JSON.stringify(meta);
|
|
70
|
+
if (metaStr.length > MAX_META_LENGTH) {
|
|
71
|
+
return err(
|
|
72
|
+
`meta must be under ${MAX_META_LENGTH / 1024}KB when serialized`,
|
|
73
|
+
"INVALID_INPUT",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (source !== undefined && source !== null) {
|
|
78
|
+
if (typeof source !== "string" || source.length > MAX_SOURCE_LENGTH) {
|
|
79
|
+
return err(
|
|
80
|
+
`source must be a string, max ${MAX_SOURCE_LENGTH} chars`,
|
|
81
|
+
"INVALID_INPUT",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (identity_key !== undefined && identity_key !== null) {
|
|
86
|
+
if (
|
|
87
|
+
typeof identity_key !== "string" ||
|
|
88
|
+
identity_key.length > MAX_IDENTITY_KEY_LENGTH
|
|
89
|
+
) {
|
|
90
|
+
return err(
|
|
91
|
+
`identity_key must be a string, max ${MAX_IDENTITY_KEY_LENGTH} chars`,
|
|
92
|
+
"INVALID_INPUT",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const name = "save_context";
|
|
100
|
+
|
|
101
|
+
export const description =
|
|
102
|
+
"Save knowledge to your vault. Creates a .md file and indexes it for search. Use for any kind of context: insights, decisions, patterns, references, or any custom kind. To update an existing entry, pass its `id` — omitted fields are preserved.";
|
|
103
|
+
|
|
104
|
+
export const inputSchema = {
|
|
105
|
+
id: z
|
|
106
|
+
.string()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe(
|
|
109
|
+
"Entry ULID to update. When provided, updates the existing entry instead of creating new. Omitted fields are preserved.",
|
|
110
|
+
),
|
|
111
|
+
kind: z
|
|
112
|
+
.string()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe(
|
|
115
|
+
"Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind). Required for new entries.",
|
|
116
|
+
),
|
|
117
|
+
title: z.string().optional().describe("Entry title (optional for insights)"),
|
|
118
|
+
body: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("Main content. Required for new entries."),
|
|
122
|
+
tags: z
|
|
123
|
+
.array(z.string())
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Tags for categorization and search"),
|
|
126
|
+
meta: z
|
|
127
|
+
.any()
|
|
128
|
+
.optional()
|
|
129
|
+
.describe(
|
|
130
|
+
"Additional structured metadata (JSON object, e.g. { language: 'js', status: 'accepted' })",
|
|
131
|
+
),
|
|
132
|
+
folder: z
|
|
133
|
+
.string()
|
|
134
|
+
.optional()
|
|
135
|
+
.describe("Subfolder within the kind directory (e.g. 'react/hooks')"),
|
|
136
|
+
source: z.string().optional().describe("Where this knowledge came from"),
|
|
137
|
+
identity_key: z
|
|
138
|
+
.string()
|
|
139
|
+
.optional()
|
|
140
|
+
.describe(
|
|
141
|
+
"Required for entity kinds (contact, project, tool, source). The unique identifier for this entity.",
|
|
142
|
+
),
|
|
143
|
+
expires_at: z.string().optional().describe("ISO date for TTL expiry"),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {object} args
|
|
148
|
+
* @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
|
|
149
|
+
* @param {import('../types.js').ToolShared} shared
|
|
150
|
+
*/
|
|
151
|
+
export async function handler(
|
|
152
|
+
{
|
|
153
|
+
id,
|
|
154
|
+
kind,
|
|
155
|
+
title,
|
|
156
|
+
body,
|
|
157
|
+
tags,
|
|
158
|
+
meta,
|
|
159
|
+
folder,
|
|
160
|
+
source,
|
|
161
|
+
identity_key,
|
|
162
|
+
expires_at,
|
|
163
|
+
},
|
|
164
|
+
ctx,
|
|
165
|
+
{ ensureIndexed },
|
|
166
|
+
) {
|
|
167
|
+
const { config } = ctx;
|
|
168
|
+
const userId = ctx.userId !== undefined ? ctx.userId : undefined;
|
|
169
|
+
|
|
170
|
+
const vaultErr = ensureVaultExists(config);
|
|
171
|
+
if (vaultErr) return vaultErr;
|
|
172
|
+
|
|
173
|
+
const inputErr = validateSaveInput({
|
|
174
|
+
kind,
|
|
175
|
+
title,
|
|
176
|
+
body,
|
|
177
|
+
tags,
|
|
178
|
+
meta,
|
|
179
|
+
source,
|
|
180
|
+
identity_key,
|
|
181
|
+
});
|
|
182
|
+
if (inputErr) return inputErr;
|
|
183
|
+
|
|
184
|
+
// ── Update mode ──
|
|
185
|
+
if (id) {
|
|
186
|
+
await ensureIndexed();
|
|
187
|
+
|
|
188
|
+
const existing = ctx.stmts.getEntryById.get(id);
|
|
189
|
+
if (!existing) return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
190
|
+
|
|
191
|
+
// Ownership check: don't leak existence across users
|
|
192
|
+
if (userId !== undefined && existing.user_id !== userId) {
|
|
193
|
+
return err(`Entry not found: ${id}`, "NOT_FOUND");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (kind && normalizeKind(kind) !== existing.kind) {
|
|
197
|
+
return err(
|
|
198
|
+
`Cannot change kind (current: "${existing.kind}"). Delete and re-create instead.`,
|
|
199
|
+
"INVALID_UPDATE",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (identity_key && identity_key !== existing.identity_key) {
|
|
203
|
+
return err(
|
|
204
|
+
`Cannot change identity_key (current: "${existing.identity_key}"). Delete and re-create instead.`,
|
|
205
|
+
"INVALID_UPDATE",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Decrypt existing entry before merge if encrypted
|
|
210
|
+
if (ctx.decrypt && existing.body_encrypted) {
|
|
211
|
+
const decrypted = await ctx.decrypt(existing);
|
|
212
|
+
existing.body = decrypted.body;
|
|
213
|
+
if (decrypted.title) existing.title = decrypted.title;
|
|
214
|
+
if (decrypted.meta) existing.meta = JSON.stringify(decrypted.meta);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const entry = updateEntryFile(ctx, existing, {
|
|
218
|
+
title,
|
|
219
|
+
body,
|
|
220
|
+
tags,
|
|
221
|
+
meta,
|
|
222
|
+
source,
|
|
223
|
+
expires_at,
|
|
224
|
+
});
|
|
225
|
+
await indexEntry(ctx, entry);
|
|
226
|
+
const relPath = entry.filePath
|
|
227
|
+
? entry.filePath.replace(config.vaultDir + "/", "")
|
|
228
|
+
: entry.filePath;
|
|
229
|
+
const parts = [`✓ Updated ${entry.kind} → ${relPath}`, ` id: ${entry.id}`];
|
|
230
|
+
if (entry.title) parts.push(` title: ${entry.title}`);
|
|
231
|
+
const entryTags = entry.tags || [];
|
|
232
|
+
if (entryTags.length) parts.push(` tags: ${entryTags.join(", ")}`);
|
|
233
|
+
parts.push("", "_Search with get_context to verify changes._");
|
|
234
|
+
return ok(parts.join("\n"));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Create mode ──
|
|
238
|
+
if (!kind) return err("Required: kind (for new entries)", "INVALID_INPUT");
|
|
239
|
+
const kindErr = ensureValidKind(kind);
|
|
240
|
+
if (kindErr) return kindErr;
|
|
241
|
+
if (!body?.trim())
|
|
242
|
+
return err("Required: body (for new entries)", "INVALID_INPUT");
|
|
243
|
+
|
|
244
|
+
// Normalize kind to canonical singular form (e.g. "insights" → "insight")
|
|
245
|
+
const normalizedKind = normalizeKind(kind);
|
|
246
|
+
|
|
247
|
+
if (categoryFor(normalizedKind) === "entity" && !identity_key) {
|
|
248
|
+
return err(
|
|
249
|
+
`Entity kind "${normalizedKind}" requires identity_key`,
|
|
250
|
+
"MISSING_IDENTITY_KEY",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Hosted tier limit enforcement (skipped in local mode — no checkLimits on ctx)
|
|
255
|
+
if (ctx.checkLimits) {
|
|
256
|
+
const usage = ctx.checkLimits();
|
|
257
|
+
if (usage.entryCount >= usage.maxEntries) {
|
|
258
|
+
return err(
|
|
259
|
+
`Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`,
|
|
260
|
+
"LIMIT_EXCEEDED",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (usage.storageMb >= usage.maxStorageMb) {
|
|
264
|
+
return err(
|
|
265
|
+
`Storage limit reached (${usage.maxStorageMb} MB). Upgrade to Pro for more storage.`,
|
|
266
|
+
"LIMIT_EXCEEDED",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await ensureIndexed();
|
|
272
|
+
|
|
273
|
+
const mergedMeta = { ...(meta || {}) };
|
|
274
|
+
if (folder) mergedMeta.folder = folder;
|
|
275
|
+
const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
|
|
276
|
+
|
|
277
|
+
const entry = await captureAndIndex(ctx, {
|
|
278
|
+
kind: normalizedKind,
|
|
279
|
+
title,
|
|
280
|
+
body,
|
|
281
|
+
meta: finalMeta,
|
|
282
|
+
tags,
|
|
283
|
+
source,
|
|
284
|
+
folder,
|
|
285
|
+
identity_key,
|
|
286
|
+
expires_at,
|
|
287
|
+
userId,
|
|
288
|
+
});
|
|
289
|
+
const relPath = entry.filePath
|
|
290
|
+
? entry.filePath.replace(config.vaultDir + "/", "")
|
|
291
|
+
: entry.filePath;
|
|
292
|
+
const parts = [`✓ Saved ${normalizedKind} → ${relPath}`, ` id: ${entry.id}`];
|
|
293
|
+
if (title) parts.push(` title: ${title}`);
|
|
294
|
+
if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
|
|
295
|
+
parts.push("", "_Use this id to update or delete later._");
|
|
296
|
+
return ok(parts.join("\n"));
|
|
297
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { reindex } from "../index/index.js";
|
|
2
|
+
import { err } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
import * as getContext from "./tools/get-context.js";
|
|
5
|
+
import * as saveContext from "./tools/save-context.js";
|
|
6
|
+
import * as listContext from "./tools/list-context.js";
|
|
7
|
+
import * as deleteContext from "./tools/delete-context.js";
|
|
8
|
+
import * as submitFeedback from "./tools/submit-feedback.js";
|
|
9
|
+
import * as ingestUrl from "./tools/ingest-url.js";
|
|
10
|
+
import * as contextStatus from "./tools/context-status.js";
|
|
11
|
+
|
|
12
|
+
const toolModules = [
|
|
13
|
+
getContext,
|
|
14
|
+
saveContext,
|
|
15
|
+
listContext,
|
|
16
|
+
deleteContext,
|
|
17
|
+
submitFeedback,
|
|
18
|
+
ingestUrl,
|
|
19
|
+
contextStatus,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const TOOL_TIMEOUT_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
export function registerTools(server, ctx) {
|
|
25
|
+
const userId = ctx.userId !== undefined ? ctx.userId : undefined;
|
|
26
|
+
|
|
27
|
+
function tracked(handler) {
|
|
28
|
+
return async (...args) => {
|
|
29
|
+
if (ctx.activeOps) ctx.activeOps.count++;
|
|
30
|
+
let timer;
|
|
31
|
+
try {
|
|
32
|
+
return await Promise.race([
|
|
33
|
+
Promise.resolve(handler(...args)),
|
|
34
|
+
new Promise((_, reject) => {
|
|
35
|
+
timer = setTimeout(
|
|
36
|
+
() => reject(new Error("TOOL_TIMEOUT")),
|
|
37
|
+
TOOL_TIMEOUT_MS,
|
|
38
|
+
);
|
|
39
|
+
}),
|
|
40
|
+
]);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e.message === "TOOL_TIMEOUT") {
|
|
43
|
+
return err(
|
|
44
|
+
"Tool timed out after 60s. Try a simpler query or run `context-vault reindex` first.",
|
|
45
|
+
"TIMEOUT",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
throw e;
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
if (ctx.activeOps) ctx.activeOps.count--;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// In hosted mode, skip reindex — DB is always in sync via writeEntry→indexEntry
|
|
57
|
+
let reindexDone = userId !== undefined ? true : false;
|
|
58
|
+
let reindexPromise = null;
|
|
59
|
+
let reindexAttempts = 0;
|
|
60
|
+
let reindexFailed = false;
|
|
61
|
+
const MAX_REINDEX_ATTEMPTS = 2;
|
|
62
|
+
|
|
63
|
+
async function ensureIndexed() {
|
|
64
|
+
if (reindexDone) return;
|
|
65
|
+
if (reindexPromise) return reindexPromise;
|
|
66
|
+
// Assign promise synchronously to prevent concurrent calls from both entering reindex()
|
|
67
|
+
const promise = reindex(ctx, { fullSync: true })
|
|
68
|
+
.then((stats) => {
|
|
69
|
+
reindexDone = true;
|
|
70
|
+
const total = stats.added + stats.updated + stats.removed;
|
|
71
|
+
if (total > 0) {
|
|
72
|
+
console.error(
|
|
73
|
+
`[context-vault] Auto-reindex: +${stats.added} ~${stats.updated} -${stats.removed} (${stats.unchanged} unchanged)`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.catch((e) => {
|
|
78
|
+
reindexAttempts++;
|
|
79
|
+
console.error(
|
|
80
|
+
`[context-vault] Auto-reindex failed (attempt ${reindexAttempts}/${MAX_REINDEX_ATTEMPTS}): ${e.message}`,
|
|
81
|
+
);
|
|
82
|
+
if (reindexAttempts >= MAX_REINDEX_ATTEMPTS) {
|
|
83
|
+
console.error(
|
|
84
|
+
`[context-vault] Giving up on auto-reindex. Run \`context-vault reindex\` manually to diagnose.`,
|
|
85
|
+
);
|
|
86
|
+
reindexDone = true;
|
|
87
|
+
reindexFailed = true;
|
|
88
|
+
} else {
|
|
89
|
+
reindexPromise = null; // Allow retry on next tool call
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
reindexPromise = promise;
|
|
93
|
+
return reindexPromise;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const shared = {
|
|
97
|
+
ensureIndexed,
|
|
98
|
+
get reindexFailed() {
|
|
99
|
+
return reindexFailed;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (const mod of toolModules) {
|
|
104
|
+
server.tool(
|
|
105
|
+
mod.name,
|
|
106
|
+
mod.description,
|
|
107
|
+
mod.inputSchema,
|
|
108
|
+
tracked((args) => mod.handler(args, ctx, shared)),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|