context-vault 2.6.1 → 2.7.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.
@@ -0,0 +1,173 @@
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
+
8
+ // ─── Input size limits (mirrors hosted validation) ────────────────────────────
9
+ const MAX_BODY_LENGTH = 100 * 1024; // 100KB
10
+ const MAX_TITLE_LENGTH = 500;
11
+ const MAX_KIND_LENGTH = 64;
12
+ const MAX_TAG_LENGTH = 100;
13
+ const MAX_TAGS_COUNT = 20;
14
+ const MAX_META_LENGTH = 10 * 1024; // 10KB
15
+ const MAX_SOURCE_LENGTH = 200;
16
+ const MAX_IDENTITY_KEY_LENGTH = 200;
17
+
18
+ /**
19
+ * Validate input fields for save_context. Returns an error response or null.
20
+ */
21
+ function validateSaveInput({ kind, title, body, tags, meta, source, identity_key }) {
22
+ if (kind !== undefined && kind !== null) {
23
+ if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
24
+ return err(`kind must be a string, max ${MAX_KIND_LENGTH} chars`, "INVALID_INPUT");
25
+ }
26
+ }
27
+ if (body !== undefined && body !== null) {
28
+ if (typeof body !== "string" || body.length > MAX_BODY_LENGTH) {
29
+ return err(`body must be a string, max ${MAX_BODY_LENGTH / 1024}KB`, "INVALID_INPUT");
30
+ }
31
+ }
32
+ if (title !== undefined && title !== null) {
33
+ if (typeof title !== "string" || title.length > MAX_TITLE_LENGTH) {
34
+ return err(`title must be a string, max ${MAX_TITLE_LENGTH} chars`, "INVALID_INPUT");
35
+ }
36
+ }
37
+ if (tags !== undefined && tags !== null) {
38
+ if (!Array.isArray(tags)) return err("tags must be an array of strings", "INVALID_INPUT");
39
+ if (tags.length > MAX_TAGS_COUNT) return err(`tags: max ${MAX_TAGS_COUNT} tags allowed`, "INVALID_INPUT");
40
+ for (const tag of tags) {
41
+ if (typeof tag !== "string" || tag.length > MAX_TAG_LENGTH) {
42
+ return err(`each tag must be a string, max ${MAX_TAG_LENGTH} chars`, "INVALID_INPUT");
43
+ }
44
+ }
45
+ }
46
+ if (meta !== undefined && meta !== null) {
47
+ const metaStr = JSON.stringify(meta);
48
+ if (metaStr.length > MAX_META_LENGTH) {
49
+ return err(`meta must be under ${MAX_META_LENGTH / 1024}KB when serialized`, "INVALID_INPUT");
50
+ }
51
+ }
52
+ if (source !== undefined && source !== null) {
53
+ if (typeof source !== "string" || source.length > MAX_SOURCE_LENGTH) {
54
+ return err(`source must be a string, max ${MAX_SOURCE_LENGTH} chars`, "INVALID_INPUT");
55
+ }
56
+ }
57
+ if (identity_key !== undefined && identity_key !== null) {
58
+ if (typeof identity_key !== "string" || identity_key.length > MAX_IDENTITY_KEY_LENGTH) {
59
+ return err(`identity_key must be a string, max ${MAX_IDENTITY_KEY_LENGTH} chars`, "INVALID_INPUT");
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export const name = "save_context";
66
+
67
+ export const description =
68
+ "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.";
69
+
70
+ export const inputSchema = {
71
+ id: z.string().optional().describe("Entry ULID to update. When provided, updates the existing entry instead of creating new. Omitted fields are preserved."),
72
+ kind: z.string().optional().describe("Entry kind — determines folder (e.g. 'insight', 'decision', 'pattern', 'reference', or any custom kind). Required for new entries."),
73
+ title: z.string().optional().describe("Entry title (optional for insights)"),
74
+ body: z.string().optional().describe("Main content. Required for new entries."),
75
+ tags: z.array(z.string()).optional().describe("Tags for categorization and search"),
76
+ meta: z.any().optional().describe("Additional structured metadata (JSON object, e.g. { language: 'js', status: 'accepted' })"),
77
+ folder: z.string().optional().describe("Subfolder within the kind directory (e.g. 'react/hooks')"),
78
+ source: z.string().optional().describe("Where this knowledge came from"),
79
+ identity_key: z.string().optional().describe("Required for entity kinds (contact, project, tool, source). The unique identifier for this entity."),
80
+ expires_at: z.string().optional().describe("ISO date for TTL expiry"),
81
+ };
82
+
83
+ /**
84
+ * @param {object} args
85
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
86
+ * @param {import('../types.js').ToolShared} shared
87
+ */
88
+ export async function handler({ id, kind, title, body, tags, meta, folder, source, identity_key, expires_at }, ctx, { ensureIndexed }) {
89
+ const { config } = ctx;
90
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
91
+
92
+ const vaultErr = ensureVaultExists(config);
93
+ if (vaultErr) return vaultErr;
94
+
95
+ const inputErr = validateSaveInput({ kind, title, body, tags, meta, source, identity_key });
96
+ if (inputErr) return inputErr;
97
+
98
+ // ── Update mode ──
99
+ if (id) {
100
+ await ensureIndexed();
101
+
102
+ const existing = ctx.stmts.getEntryById.get(id);
103
+ if (!existing) return err(`Entry not found: ${id}`, "NOT_FOUND");
104
+
105
+ // Ownership check: don't leak existence across users
106
+ if (userId !== undefined && existing.user_id !== userId) {
107
+ return err(`Entry not found: ${id}`, "NOT_FOUND");
108
+ }
109
+
110
+ if (kind && normalizeKind(kind) !== existing.kind) {
111
+ return err(`Cannot change kind (current: "${existing.kind}"). Delete and re-create instead.`, "INVALID_UPDATE");
112
+ }
113
+ if (identity_key && identity_key !== existing.identity_key) {
114
+ return err(`Cannot change identity_key (current: "${existing.identity_key}"). Delete and re-create instead.`, "INVALID_UPDATE");
115
+ }
116
+
117
+ // Decrypt existing entry before merge if encrypted
118
+ if (ctx.decrypt && existing.body_encrypted) {
119
+ const decrypted = await ctx.decrypt(existing);
120
+ existing.body = decrypted.body;
121
+ if (decrypted.title) existing.title = decrypted.title;
122
+ if (decrypted.meta) existing.meta = JSON.stringify(decrypted.meta);
123
+ }
124
+
125
+ const entry = updateEntryFile(ctx, existing, { title, body, tags, meta, source, expires_at });
126
+ await indexEntry(ctx, entry);
127
+ const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
128
+ const parts = [`✓ Updated ${entry.kind} → ${relPath}`, ` id: ${entry.id}`];
129
+ if (entry.title) parts.push(` title: ${entry.title}`);
130
+ const entryTags = entry.tags || [];
131
+ if (entryTags.length) parts.push(` tags: ${entryTags.join(", ")}`);
132
+ parts.push("", "_Search with get_context to verify changes._");
133
+ return ok(parts.join("\n"));
134
+ }
135
+
136
+ // ── Create mode ──
137
+ if (!kind) return err("Required: kind (for new entries)", "INVALID_INPUT");
138
+ const kindErr = ensureValidKind(kind);
139
+ if (kindErr) return kindErr;
140
+ if (!body?.trim()) return err("Required: body (for new entries)", "INVALID_INPUT");
141
+
142
+ // Normalize kind to canonical singular form (e.g. "insights" → "insight")
143
+ const normalizedKind = normalizeKind(kind);
144
+
145
+ if (categoryFor(normalizedKind) === "entity" && !identity_key) {
146
+ return err(`Entity kind "${normalizedKind}" requires identity_key`, "MISSING_IDENTITY_KEY");
147
+ }
148
+
149
+ // Hosted tier limit enforcement (skipped in local mode — no checkLimits on ctx)
150
+ if (ctx.checkLimits) {
151
+ const usage = ctx.checkLimits();
152
+ if (usage.entryCount >= usage.maxEntries) {
153
+ return err(`Entry limit reached (${usage.maxEntries}). Upgrade to Pro for unlimited entries.`, "LIMIT_EXCEEDED");
154
+ }
155
+ if (usage.storageMb >= usage.maxStorageMb) {
156
+ return err(`Storage limit reached (${usage.maxStorageMb} MB). Upgrade to Pro for more storage.`, "LIMIT_EXCEEDED");
157
+ }
158
+ }
159
+
160
+ await ensureIndexed();
161
+
162
+ const mergedMeta = { ...(meta || {}) };
163
+ if (folder) mergedMeta.folder = folder;
164
+ const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
165
+
166
+ const entry = await captureAndIndex(ctx, { kind: normalizedKind, title, body, meta: finalMeta, tags, source, folder, identity_key, expires_at, userId }, indexEntry);
167
+ const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
168
+ const parts = [`✓ Saved ${normalizedKind} → ${relPath}`, ` id: ${entry.id}`];
169
+ if (title) parts.push(` title: ${title}`);
170
+ if (tags?.length) parts.push(` tags: ${tags.join(", ")}`);
171
+ parts.push("", "_Use this id to update or delete later._");
172
+ return ok(parts.join("\n"));
173
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { captureAndIndex } from "../../capture/index.js";
3
+ import { indexEntry } from "../../index/index.js";
4
+ import { ok, ensureVaultExists } from "../helpers.js";
5
+
6
+ export const name = "submit_feedback";
7
+
8
+ export const description =
9
+ "Report a bug, request a feature, or suggest an improvement. Feedback is stored in the vault and triaged by the development pipeline.";
10
+
11
+ export const inputSchema = {
12
+ type: z.enum(["bug", "feature", "improvement"]).describe("Type of feedback"),
13
+ title: z.string().describe("Short summary of the feedback"),
14
+ body: z.string().describe("Detailed description"),
15
+ severity: z.enum(["low", "medium", "high"]).optional().describe("Severity level (default: medium)"),
16
+ };
17
+
18
+ /**
19
+ * @param {object} args
20
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} ctx
21
+ * @param {import('../types.js').ToolShared} shared
22
+ */
23
+ export async function handler({ type, title, body, severity }, ctx, { ensureIndexed }) {
24
+ const { config } = ctx;
25
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
26
+
27
+ const vaultErr = ensureVaultExists(config);
28
+ if (vaultErr) return vaultErr;
29
+
30
+ await ensureIndexed();
31
+
32
+ const effectiveSeverity = severity || "medium";
33
+ const entry = await captureAndIndex(
34
+ ctx,
35
+ {
36
+ kind: "feedback",
37
+ title,
38
+ body,
39
+ tags: [type, effectiveSeverity],
40
+ source: "submit_feedback",
41
+ meta: { feedback_type: type, severity: effectiveSeverity, status: "new" },
42
+ userId,
43
+ },
44
+ indexEntry
45
+ );
46
+
47
+ const relPath = entry.filePath ? entry.filePath.replace(config.vaultDir + "/", "") : entry.filePath;
48
+ return ok(`Feedback submitted: ${type} [${effectiveSeverity}] → ${relPath}\n id: ${entry.id}\n title: ${title}`);
49
+ }