@waynesutton/agent-memory 0.0.1-alpha.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/.claude/settings.json +9 -0
- package/.claude/settings.local.json +7 -0
- package/AGENTS.md +113 -0
- package/CLAUDE.md +79 -0
- package/README.md +1003 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +192 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/parsers/claude-code.d.ts +3 -0
- package/dist/cli/parsers/claude-code.d.ts.map +1 -0
- package/dist/cli/parsers/claude-code.js +75 -0
- package/dist/cli/parsers/claude-code.js.map +1 -0
- package/dist/cli/parsers/codex.d.ts +3 -0
- package/dist/cli/parsers/codex.d.ts.map +1 -0
- package/dist/cli/parsers/codex.js +42 -0
- package/dist/cli/parsers/codex.js.map +1 -0
- package/dist/cli/parsers/conductor.d.ts +3 -0
- package/dist/cli/parsers/conductor.d.ts.map +1 -0
- package/dist/cli/parsers/conductor.js +43 -0
- package/dist/cli/parsers/conductor.js.map +1 -0
- package/dist/cli/parsers/cursor.d.ts +3 -0
- package/dist/cli/parsers/cursor.d.ts.map +1 -0
- package/dist/cli/parsers/cursor.js +50 -0
- package/dist/cli/parsers/cursor.js.map +1 -0
- package/dist/cli/parsers/index.d.ts +12 -0
- package/dist/cli/parsers/index.d.ts.map +1 -0
- package/dist/cli/parsers/index.js +27 -0
- package/dist/cli/parsers/index.js.map +1 -0
- package/dist/cli/parsers/opencode.d.ts +3 -0
- package/dist/cli/parsers/opencode.d.ts.map +1 -0
- package/dist/cli/parsers/opencode.js +72 -0
- package/dist/cli/parsers/opencode.js.map +1 -0
- package/dist/cli/parsers/parsers.test.d.ts +2 -0
- package/dist/cli/parsers/parsers.test.d.ts.map +1 -0
- package/dist/cli/parsers/parsers.test.js +151 -0
- package/dist/cli/parsers/parsers.test.js.map +1 -0
- package/dist/cli/parsers/pi.d.ts +3 -0
- package/dist/cli/parsers/pi.d.ts.map +1 -0
- package/dist/cli/parsers/pi.js +43 -0
- package/dist/cli/parsers/pi.js.map +1 -0
- package/dist/cli/parsers/types.d.ts +25 -0
- package/dist/cli/parsers/types.d.ts.map +1 -0
- package/dist/cli/parsers/types.js +2 -0
- package/dist/cli/parsers/types.js.map +1 -0
- package/dist/cli/parsers/vscode-copilot.d.ts +3 -0
- package/dist/cli/parsers/vscode-copilot.d.ts.map +1 -0
- package/dist/cli/parsers/vscode-copilot.js +69 -0
- package/dist/cli/parsers/vscode-copilot.js.map +1 -0
- package/dist/cli/parsers/zed.d.ts +3 -0
- package/dist/cli/parsers/zed.d.ts.map +1 -0
- package/dist/cli/parsers/zed.js +43 -0
- package/dist/cli/parsers/zed.js.map +1 -0
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +78 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/cli/type-extractor.d.ts +25 -0
- package/dist/cli/type-extractor.d.ts.map +1 -0
- package/dist/cli/type-extractor.js +254 -0
- package/dist/cli/type-extractor.js.map +1 -0
- package/dist/cli/type-extractor.test.d.ts +2 -0
- package/dist/cli/type-extractor.test.d.ts.map +1 -0
- package/dist/cli/type-extractor.test.js +173 -0
- package/dist/cli/type-extractor.test.js.map +1 -0
- package/dist/client/http.d.ts +44 -0
- package/dist/client/http.d.ts.map +1 -0
- package/dist/client/http.js +311 -0
- package/dist/client/http.js.map +1 -0
- package/dist/client/index.d.ts +158 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +256 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +12 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +13 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +18 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +42 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +39 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/actions.d.ts +42 -0
- package/dist/component/actions.d.ts.map +1 -0
- package/dist/component/actions.js +405 -0
- package/dist/component/actions.js.map +1 -0
- package/dist/component/apiKeyMutations.d.ts +29 -0
- package/dist/component/apiKeyMutations.d.ts.map +1 -0
- package/dist/component/apiKeyMutations.js +149 -0
- package/dist/component/apiKeyMutations.js.map +1 -0
- package/dist/component/apiKeyQueries.d.ts +37 -0
- package/dist/component/apiKeyQueries.d.ts.map +1 -0
- package/dist/component/apiKeyQueries.js +127 -0
- package/dist/component/apiKeyQueries.js.map +1 -0
- package/dist/component/checksum.d.ts +6 -0
- package/dist/component/checksum.d.ts.map +1 -0
- package/dist/component/checksum.js +14 -0
- package/dist/component/checksum.js.map +1 -0
- package/dist/component/checksum.test.d.ts +2 -0
- package/dist/component/checksum.test.d.ts.map +1 -0
- package/dist/component/checksum.test.js +27 -0
- package/dist/component/checksum.test.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +4 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/cronActions.d.ts +3 -0
- package/dist/component/cronActions.d.ts.map +1 -0
- package/dist/component/cronActions.js +38 -0
- package/dist/component/cronActions.js.map +1 -0
- package/dist/component/cronQueries.d.ts +6 -0
- package/dist/component/cronQueries.d.ts.map +1 -0
- package/dist/component/cronQueries.js +38 -0
- package/dist/component/cronQueries.js.map +1 -0
- package/dist/component/crons.d.ts +3 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +18 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/format.d.ts +11 -0
- package/dist/component/format.d.ts.map +1 -0
- package/dist/component/format.js +175 -0
- package/dist/component/format.js.map +1 -0
- package/dist/component/format.test.d.ts +2 -0
- package/dist/component/format.test.d.ts.map +1 -0
- package/dist/component/format.test.js +118 -0
- package/dist/component/format.test.js.map +1 -0
- package/dist/component/mutations.d.ts +158 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +745 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +94 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +574 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +278 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +161 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +571 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/shared.d.ts +126 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +67 -0
- package/dist/shared.js.map +1 -0
- package/dist/test.d.ts +23 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +21 -0
- package/dist/test.js.map +1 -0
- package/eslint.config.js +15 -0
- package/example/convex/convex.config.ts +7 -0
- package/example/convex/memory.ts +129 -0
- package/llms.md +175 -0
- package/llms.txt +126 -0
- package/package.json +80 -0
- package/prds/API-REFERENCE.md +935 -0
- package/prds/SETUP.md +682 -0
- package/src/cli/index.ts +254 -0
- package/src/cli/parsers/claude-code.ts +80 -0
- package/src/cli/parsers/codex.ts +45 -0
- package/src/cli/parsers/conductor.ts +47 -0
- package/src/cli/parsers/cursor.ts +55 -0
- package/src/cli/parsers/index.ts +30 -0
- package/src/cli/parsers/opencode.ts +84 -0
- package/src/cli/parsers/parsers.test.ts +201 -0
- package/src/cli/parsers/pi.ts +47 -0
- package/src/cli/parsers/types.ts +26 -0
- package/src/cli/parsers/vscode-copilot.ts +78 -0
- package/src/cli/parsers/zed.ts +47 -0
- package/src/cli/sync.ts +110 -0
- package/src/cli/type-extractor.test.ts +241 -0
- package/src/cli/type-extractor.ts +331 -0
- package/src/client/http.ts +415 -0
- package/src/client/index.ts +519 -0
- package/src/component/_generated/api.ts +14 -0
- package/src/component/_generated/dataModel.ts +20 -0
- package/src/component/_generated/server.ts +64 -0
- package/src/component/actions.ts +558 -0
- package/src/component/apiKeyMutations.ts +175 -0
- package/src/component/apiKeyQueries.ts +156 -0
- package/src/component/checksum.test.ts +31 -0
- package/src/component/checksum.ts +13 -0
- package/src/component/convex.config.ts +5 -0
- package/src/component/cronActions.ts +52 -0
- package/src/component/cronQueries.ts +42 -0
- package/src/component/crons.ts +34 -0
- package/src/component/format.test.ts +133 -0
- package/src/component/format.ts +232 -0
- package/src/component/mutations.ts +824 -0
- package/src/component/queries.ts +684 -0
- package/src/component/schema.ts +207 -0
- package/src/mcp/server.ts +695 -0
- package/src/shared.ts +251 -0
- package/src/test.ts +32 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { mutation } from "./_generated/server.js";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
// ── Hash helper ─────────────────────────────────────────────────────
|
|
5
|
+
// Simple FNV-1a based hash for API key storage.
|
|
6
|
+
// We use the same checksum function as the rest of the component
|
|
7
|
+
// for consistency. For API keys the security model is "bearer token
|
|
8
|
+
// stored in database" — the hash prevents plaintext key exposure
|
|
9
|
+
// if the database is inspected, but the real security boundary is
|
|
10
|
+
// Convex's deployment isolation.
|
|
11
|
+
|
|
12
|
+
function hashKey(key: string): string {
|
|
13
|
+
let hash = 2166136261;
|
|
14
|
+
for (let i = 0; i < key.length; i++) {
|
|
15
|
+
hash ^= key.charCodeAt(i);
|
|
16
|
+
hash = Math.imul(hash, 16777619);
|
|
17
|
+
}
|
|
18
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function generateKey(): string {
|
|
22
|
+
// Generate a URL-safe random key with "am_" prefix
|
|
23
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
24
|
+
let key = "am_";
|
|
25
|
+
for (let i = 0; i < 40; i++) {
|
|
26
|
+
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
27
|
+
}
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── createApiKey ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export const createApiKey = mutation({
|
|
34
|
+
args: {
|
|
35
|
+
projectId: v.string(),
|
|
36
|
+
name: v.string(),
|
|
37
|
+
permissions: v.array(v.string()),
|
|
38
|
+
rateLimitOverride: v.optional(
|
|
39
|
+
v.object({
|
|
40
|
+
requestsPerWindow: v.float64(),
|
|
41
|
+
windowMs: v.float64(),
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
expiresAt: v.optional(v.float64()),
|
|
45
|
+
},
|
|
46
|
+
returns: v.object({
|
|
47
|
+
key: v.string(), // plaintext key — only returned once
|
|
48
|
+
keyHash: v.string(),
|
|
49
|
+
}),
|
|
50
|
+
handler: async (ctx, args) => {
|
|
51
|
+
const key = generateKey();
|
|
52
|
+
const keyHash = hashKey(key);
|
|
53
|
+
|
|
54
|
+
await ctx.db.insert("apiKeys", {
|
|
55
|
+
keyHash,
|
|
56
|
+
projectId: args.projectId,
|
|
57
|
+
name: args.name,
|
|
58
|
+
permissions: args.permissions,
|
|
59
|
+
rateLimitOverride: args.rateLimitOverride,
|
|
60
|
+
expiresAt: args.expiresAt,
|
|
61
|
+
revoked: false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return { key, keyHash };
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── revokeApiKey ────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export const revokeApiKey = mutation({
|
|
71
|
+
args: {
|
|
72
|
+
keyHash: v.string(),
|
|
73
|
+
},
|
|
74
|
+
returns: v.null(),
|
|
75
|
+
handler: async (ctx, args) => {
|
|
76
|
+
const existing = await ctx.db
|
|
77
|
+
.query("apiKeys")
|
|
78
|
+
.withIndex("by_key", (q: any) => q.eq("keyHash", args.keyHash))
|
|
79
|
+
.first();
|
|
80
|
+
|
|
81
|
+
if (!existing) throw new Error(`API key not found: ${args.keyHash}`);
|
|
82
|
+
|
|
83
|
+
await ctx.db.patch(existing._id, { revoked: true });
|
|
84
|
+
return null;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── consumeRateLimit ────────────────────────────────────────────────
|
|
89
|
+
// Atomically checks and increments the rate limit counter.
|
|
90
|
+
// Returns whether the request is allowed and how many tokens remain.
|
|
91
|
+
|
|
92
|
+
export const consumeRateLimit = mutation({
|
|
93
|
+
args: {
|
|
94
|
+
keyHash: v.string(),
|
|
95
|
+
requestsPerWindow: v.float64(),
|
|
96
|
+
windowMs: v.float64(),
|
|
97
|
+
},
|
|
98
|
+
returns: v.object({
|
|
99
|
+
allowed: v.boolean(),
|
|
100
|
+
remaining: v.float64(),
|
|
101
|
+
retryAfterMs: v.float64(),
|
|
102
|
+
}),
|
|
103
|
+
handler: async (ctx, args) => {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const windowStart =
|
|
106
|
+
Math.floor(now / args.windowMs) * args.windowMs;
|
|
107
|
+
|
|
108
|
+
// Look for existing token record for this window
|
|
109
|
+
const existing = await ctx.db
|
|
110
|
+
.query("rateLimitTokens")
|
|
111
|
+
.withIndex("by_key_window", (q: any) =>
|
|
112
|
+
q.eq("keyHash", args.keyHash).eq("windowStart", windowStart),
|
|
113
|
+
)
|
|
114
|
+
.first();
|
|
115
|
+
|
|
116
|
+
if (!existing) {
|
|
117
|
+
// First request in this window
|
|
118
|
+
await ctx.db.insert("rateLimitTokens", {
|
|
119
|
+
keyHash: args.keyHash,
|
|
120
|
+
windowStart,
|
|
121
|
+
tokenCount: 1,
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
allowed: true,
|
|
125
|
+
remaining: args.requestsPerWindow - 1,
|
|
126
|
+
retryAfterMs: 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (existing.tokenCount >= args.requestsPerWindow) {
|
|
131
|
+
// Rate limited
|
|
132
|
+
const windowEnd = windowStart + args.windowMs;
|
|
133
|
+
return {
|
|
134
|
+
allowed: false,
|
|
135
|
+
remaining: 0,
|
|
136
|
+
retryAfterMs: windowEnd - now,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Consume a token
|
|
141
|
+
await ctx.db.patch(existing._id, {
|
|
142
|
+
tokenCount: existing.tokenCount + 1,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
allowed: true,
|
|
147
|
+
remaining: args.requestsPerWindow - existing.tokenCount - 1,
|
|
148
|
+
retryAfterMs: 0,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── cleanupRateLimitTokens (internal, used by cron) ─────────────────
|
|
154
|
+
|
|
155
|
+
import { internalMutation } from "./_generated/server.js";
|
|
156
|
+
|
|
157
|
+
export const cleanupRateLimitTokens = internalMutation({
|
|
158
|
+
args: {},
|
|
159
|
+
returns: v.object({ deleted: v.float64() }),
|
|
160
|
+
handler: async (ctx) => {
|
|
161
|
+
// Delete token records older than 1 hour (well past any window)
|
|
162
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
163
|
+
const old = await ctx.db.query("rateLimitTokens").take(500);
|
|
164
|
+
|
|
165
|
+
let deleted = 0;
|
|
166
|
+
for (const record of old) {
|
|
167
|
+
if (record.windowStart < cutoff) {
|
|
168
|
+
await ctx.db.delete(record._id);
|
|
169
|
+
deleted++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { deleted };
|
|
174
|
+
},
|
|
175
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { query } from "./_generated/server.js";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
// ── Hash helper (same as in apiKeyMutations.ts) ─────────────────────
|
|
5
|
+
|
|
6
|
+
function hashKey(key: string): string {
|
|
7
|
+
let hash = 2166136261;
|
|
8
|
+
for (let i = 0; i < key.length; i++) {
|
|
9
|
+
hash ^= key.charCodeAt(i);
|
|
10
|
+
hash = Math.imul(hash, 16777619);
|
|
11
|
+
}
|
|
12
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── validateApiKey ──────────────────────────────────────────────────
|
|
16
|
+
// Called by HTTP handlers to verify an API key and get its permissions.
|
|
17
|
+
|
|
18
|
+
export const validateApiKey = query({
|
|
19
|
+
args: {
|
|
20
|
+
key: v.string(), // plaintext key from Authorization header
|
|
21
|
+
},
|
|
22
|
+
returns: v.union(
|
|
23
|
+
v.object({
|
|
24
|
+
valid: v.literal(true),
|
|
25
|
+
keyHash: v.string(),
|
|
26
|
+
projectId: v.string(),
|
|
27
|
+
permissions: v.array(v.string()),
|
|
28
|
+
rateLimit: v.object({
|
|
29
|
+
requestsPerWindow: v.float64(),
|
|
30
|
+
windowMs: v.float64(),
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
v.object({
|
|
34
|
+
valid: v.literal(false),
|
|
35
|
+
reason: v.string(),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
handler: async (ctx, args) => {
|
|
39
|
+
const keyHash = hashKey(args.key);
|
|
40
|
+
|
|
41
|
+
const apiKey = await ctx.db
|
|
42
|
+
.query("apiKeys")
|
|
43
|
+
.withIndex("by_key", (q: any) => q.eq("keyHash", keyHash))
|
|
44
|
+
.first();
|
|
45
|
+
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
return { valid: false as const, reason: "Invalid API key" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (apiKey.revoked) {
|
|
51
|
+
return { valid: false as const, reason: "API key has been revoked" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (apiKey.expiresAt && apiKey.expiresAt < Date.now()) {
|
|
55
|
+
return { valid: false as const, reason: "API key has expired" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Determine rate limit: key override > project setting > global default
|
|
59
|
+
let rateLimit = { requestsPerWindow: 100, windowMs: 60000 };
|
|
60
|
+
|
|
61
|
+
if (apiKey.rateLimitOverride) {
|
|
62
|
+
rateLimit = apiKey.rateLimitOverride;
|
|
63
|
+
} else {
|
|
64
|
+
// Check project settings
|
|
65
|
+
const project = await ctx.db
|
|
66
|
+
.query("projects")
|
|
67
|
+
.withIndex("by_projectId", (q: any) =>
|
|
68
|
+
q.eq("projectId", apiKey.projectId),
|
|
69
|
+
)
|
|
70
|
+
.first();
|
|
71
|
+
|
|
72
|
+
if (project?.settings.apiRateLimit) {
|
|
73
|
+
rateLimit = project.settings.apiRateLimit;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
valid: true as const,
|
|
79
|
+
keyHash,
|
|
80
|
+
projectId: apiKey.projectId,
|
|
81
|
+
permissions: apiKey.permissions,
|
|
82
|
+
rateLimit,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── listApiKeys ─────────────────────────────────────────────────────
|
|
88
|
+
// List all API keys for a project (without revealing the key itself).
|
|
89
|
+
|
|
90
|
+
export const listApiKeys = query({
|
|
91
|
+
args: {
|
|
92
|
+
projectId: v.string(),
|
|
93
|
+
},
|
|
94
|
+
returns: v.array(
|
|
95
|
+
v.object({
|
|
96
|
+
_id: v.string(),
|
|
97
|
+
keyHash: v.string(),
|
|
98
|
+
projectId: v.string(),
|
|
99
|
+
name: v.string(),
|
|
100
|
+
permissions: v.array(v.string()),
|
|
101
|
+
rateLimitOverride: v.optional(
|
|
102
|
+
v.object({
|
|
103
|
+
requestsPerWindow: v.float64(),
|
|
104
|
+
windowMs: v.float64(),
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
lastUsedAt: v.optional(v.float64()),
|
|
108
|
+
expiresAt: v.optional(v.float64()),
|
|
109
|
+
revoked: v.boolean(),
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
handler: async (ctx, args) => {
|
|
113
|
+
const keys = await ctx.db
|
|
114
|
+
.query("apiKeys")
|
|
115
|
+
.withIndex("by_project", (q: any) =>
|
|
116
|
+
q.eq("projectId", args.projectId).eq("revoked", false),
|
|
117
|
+
)
|
|
118
|
+
.take(100);
|
|
119
|
+
|
|
120
|
+
return keys.map((k) => ({
|
|
121
|
+
_id: k._id as unknown as string,
|
|
122
|
+
keyHash: k.keyHash,
|
|
123
|
+
projectId: k.projectId,
|
|
124
|
+
name: k.name,
|
|
125
|
+
permissions: k.permissions,
|
|
126
|
+
rateLimitOverride: k.rateLimitOverride,
|
|
127
|
+
lastUsedAt: k.lastUsedAt,
|
|
128
|
+
expiresAt: k.expiresAt,
|
|
129
|
+
revoked: k.revoked,
|
|
130
|
+
}));
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── updateKeyLastUsed ───────────────────────────────────────────────
|
|
135
|
+
// Update the lastUsedAt timestamp on an API key (fire-and-forget from HTTP handler).
|
|
136
|
+
|
|
137
|
+
import { mutation } from "./_generated/server.js";
|
|
138
|
+
|
|
139
|
+
export const updateKeyLastUsed = mutation({
|
|
140
|
+
args: {
|
|
141
|
+
keyHash: v.string(),
|
|
142
|
+
},
|
|
143
|
+
returns: v.null(),
|
|
144
|
+
handler: async (ctx, args) => {
|
|
145
|
+
const apiKey = await ctx.db
|
|
146
|
+
.query("apiKeys")
|
|
147
|
+
.withIndex("by_key", (q: any) => q.eq("keyHash", args.keyHash))
|
|
148
|
+
.first();
|
|
149
|
+
|
|
150
|
+
if (apiKey) {
|
|
151
|
+
await ctx.db.patch(apiKey._id, { lastUsedAt: Date.now() });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeChecksum } from "./checksum.js";
|
|
3
|
+
|
|
4
|
+
describe("computeChecksum", () => {
|
|
5
|
+
it("returns a hex string", () => {
|
|
6
|
+
const hash = computeChecksum("hello world");
|
|
7
|
+
expect(hash).toMatch(/^[0-9a-f]{8}$/);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns consistent results", () => {
|
|
11
|
+
const a = computeChecksum("test content");
|
|
12
|
+
const b = computeChecksum("test content");
|
|
13
|
+
expect(a).toBe(b);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns different results for different inputs", () => {
|
|
17
|
+
const a = computeChecksum("content A");
|
|
18
|
+
const b = computeChecksum("content B");
|
|
19
|
+
expect(a).not.toBe(b);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("handles empty string", () => {
|
|
23
|
+
const hash = computeChecksum("");
|
|
24
|
+
expect(hash).toMatch(/^[0-9a-f]{8}$/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("handles unicode content", () => {
|
|
28
|
+
const hash = computeChecksum("こんにちは世界 🌍");
|
|
29
|
+
expect(hash).toMatch(/^[0-9a-f]{8}$/);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute a simple hash of content for change detection.
|
|
3
|
+
* Uses a fast string hash (FNV-1a) — not cryptographic, just for diffing.
|
|
4
|
+
*/
|
|
5
|
+
export function computeChecksum(content: string): string {
|
|
6
|
+
let hash = 2166136261;
|
|
7
|
+
for (let i = 0; i < content.length; i++) {
|
|
8
|
+
hash ^= content.charCodeAt(i);
|
|
9
|
+
hash = Math.imul(hash, 16777619);
|
|
10
|
+
}
|
|
11
|
+
// Convert to unsigned 32-bit hex
|
|
12
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
13
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { internalAction } from "./_generated/server.js";
|
|
2
|
+
import { internal } from "./_generated/api.js";
|
|
3
|
+
import { v } from "convex/values";
|
|
4
|
+
|
|
5
|
+
// ── Decay: iterate all projects and apply decay ─────────────────────
|
|
6
|
+
|
|
7
|
+
export const runDecayForAllProjects = internalAction({
|
|
8
|
+
args: {},
|
|
9
|
+
returns: v.null(),
|
|
10
|
+
handler: async (ctx) => {
|
|
11
|
+
// Get all projects that have decay enabled
|
|
12
|
+
const projects = await ctx.runQuery(
|
|
13
|
+
internal.cronQueries.listDecayEnabledProjects,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
for (const project of projects) {
|
|
17
|
+
const halfLifeDays =
|
|
18
|
+
project.settings.decayHalfLifeDays ?? 30;
|
|
19
|
+
|
|
20
|
+
await ctx.runMutation(internal.mutations.applyDecay, {
|
|
21
|
+
projectId: project.projectId,
|
|
22
|
+
halfLifeDays,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── History cleanup: iterate all projects ───────────────────────────
|
|
31
|
+
|
|
32
|
+
export const runHistoryCleanupForAllProjects = internalAction({
|
|
33
|
+
args: {},
|
|
34
|
+
returns: v.null(),
|
|
35
|
+
handler: async (ctx) => {
|
|
36
|
+
const projects = await ctx.runQuery(
|
|
37
|
+
internal.cronQueries.listAllProjectIds,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// 90 days in milliseconds
|
|
41
|
+
const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
for (const projectId of projects) {
|
|
44
|
+
await ctx.runMutation(internal.mutations.cleanupOldHistory, {
|
|
45
|
+
projectId,
|
|
46
|
+
olderThanMs: ninetyDaysMs,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { internalQuery } from "./_generated/server.js";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
// List projects that have relevance decay enabled
|
|
5
|
+
export const listDecayEnabledProjects = internalQuery({
|
|
6
|
+
args: {},
|
|
7
|
+
returns: v.array(
|
|
8
|
+
v.object({
|
|
9
|
+
projectId: v.string(),
|
|
10
|
+
settings: v.object({
|
|
11
|
+
autoSync: v.boolean(),
|
|
12
|
+
syncFormats: v.array(v.string()),
|
|
13
|
+
embeddingModel: v.optional(v.string()),
|
|
14
|
+
embeddingDimensions: v.optional(v.float64()),
|
|
15
|
+
factExtractionPrompt: v.optional(v.string()),
|
|
16
|
+
updateDecisionPrompt: v.optional(v.string()),
|
|
17
|
+
decayEnabled: v.optional(v.boolean()),
|
|
18
|
+
decayHalfLifeDays: v.optional(v.float64()),
|
|
19
|
+
}),
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
handler: async (ctx) => {
|
|
23
|
+
const allProjects = await ctx.db.query("projects").take(100);
|
|
24
|
+
|
|
25
|
+
return allProjects
|
|
26
|
+
.filter((p) => p.settings.decayEnabled === true)
|
|
27
|
+
.map((p) => ({
|
|
28
|
+
projectId: p.projectId,
|
|
29
|
+
settings: p.settings,
|
|
30
|
+
}));
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// List all project IDs (for cleanup cron)
|
|
35
|
+
export const listAllProjectIds = internalQuery({
|
|
36
|
+
args: {},
|
|
37
|
+
returns: v.array(v.string()),
|
|
38
|
+
handler: async (ctx) => {
|
|
39
|
+
const allProjects = await ctx.db.query("projects").take(100);
|
|
40
|
+
return allProjects.map((p) => p.projectId);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cronJobs } from "convex/server";
|
|
2
|
+
import { internal } from "./_generated/api.js";
|
|
3
|
+
|
|
4
|
+
const crons = cronJobs();
|
|
5
|
+
|
|
6
|
+
// Run relevance decay daily at 3 AM UTC
|
|
7
|
+
// This reduces priority of stale, low-access memories so they don't
|
|
8
|
+
// dominate context bundles over time.
|
|
9
|
+
// Note: The cron calls an internal action that iterates projects with
|
|
10
|
+
// decay enabled and applies the decay function to each.
|
|
11
|
+
crons.daily(
|
|
12
|
+
"relevance-decay",
|
|
13
|
+
{ hourUTC: 3, minuteUTC: 0 },
|
|
14
|
+
internal.cronActions.runDecayForAllProjects,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// Clean up old history entries weekly (Sundays at 4 AM UTC)
|
|
18
|
+
// Keeps the memoryHistory table from growing unbounded.
|
|
19
|
+
// Retains the last 90 days of history by default.
|
|
20
|
+
crons.weekly(
|
|
21
|
+
"cleanup-old-history",
|
|
22
|
+
{ dayOfWeek: "sunday", hourUTC: 4, minuteUTC: 0 },
|
|
23
|
+
internal.cronActions.runHistoryCleanupForAllProjects,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Clean up expired rate limit token records hourly
|
|
27
|
+
// Removes window records older than 1 hour to prevent unbounded growth.
|
|
28
|
+
crons.interval(
|
|
29
|
+
"cleanup-rate-limit-tokens",
|
|
30
|
+
{ hours: 1 },
|
|
31
|
+
internal.apiKeyMutations.cleanupRateLimitTokens,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export default crons;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatMemoryForTool } from "./format.js";
|
|
3
|
+
|
|
4
|
+
const baseMemory = {
|
|
5
|
+
_id: "test-id" as any,
|
|
6
|
+
_creationTime: Date.now(),
|
|
7
|
+
projectId: "test-project",
|
|
8
|
+
scope: "project" as const,
|
|
9
|
+
title: "api-rules",
|
|
10
|
+
content: "# API Rules\n\n- Use camelCase\n- Return JSON",
|
|
11
|
+
memoryType: "instruction" as const,
|
|
12
|
+
tags: ["api", "style"],
|
|
13
|
+
priority: 0.9,
|
|
14
|
+
source: "claude-code",
|
|
15
|
+
checksum: "abc12345",
|
|
16
|
+
archived: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("formatMemoryForTool", () => {
|
|
20
|
+
describe("claude-code", () => {
|
|
21
|
+
it("formats instructions as .claude/rules/*.md", () => {
|
|
22
|
+
const result = formatMemoryForTool(baseMemory, "claude-code", "my-project");
|
|
23
|
+
expect(result.path).toBe(".claude/rules/api-rules.md");
|
|
24
|
+
expect(result.content).toContain("# API Rules");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("formats learnings under .claude/projects/", () => {
|
|
28
|
+
const learning = { ...baseMemory, memoryType: "learning" as const };
|
|
29
|
+
const result = formatMemoryForTool(learning, "claude-code", "my-project");
|
|
30
|
+
expect(result.path).toBe(".claude/projects/my-project/memory/api-rules.md");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("includes paths as YAML frontmatter", () => {
|
|
34
|
+
const withPaths = { ...baseMemory, paths: ["src/**/*.ts"] };
|
|
35
|
+
const result = formatMemoryForTool(withPaths, "claude-code");
|
|
36
|
+
expect(result.content).toContain("---");
|
|
37
|
+
expect(result.content).toContain("paths:");
|
|
38
|
+
expect(result.content).toContain("src/**/*.ts");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("cursor", () => {
|
|
43
|
+
it("formats as .cursor/rules/*.mdc", () => {
|
|
44
|
+
const result = formatMemoryForTool(baseMemory, "cursor");
|
|
45
|
+
expect(result.path).toBe(".cursor/rules/api-rules.mdc");
|
|
46
|
+
expect(result.content).toContain("description: api-rules");
|
|
47
|
+
expect(result.content).toContain("alwaysApply: true"); // priority 0.9 >= 0.8
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("sets alwaysApply false for low priority", () => {
|
|
51
|
+
const low = { ...baseMemory, priority: 0.3 };
|
|
52
|
+
const result = formatMemoryForTool(low, "cursor");
|
|
53
|
+
expect(result.content).toContain("alwaysApply: false");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("opencode", () => {
|
|
58
|
+
it("formats instructions as AGENTS.md sections", () => {
|
|
59
|
+
const result = formatMemoryForTool(baseMemory, "opencode");
|
|
60
|
+
expect(result.path).toBe("AGENTS.md");
|
|
61
|
+
expect(result.content).toContain("## api-rules");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("formats journals as separate files", () => {
|
|
65
|
+
const journal = { ...baseMemory, memoryType: "journal" as const };
|
|
66
|
+
const result = formatMemoryForTool(journal, "opencode");
|
|
67
|
+
expect(result.path).toBe("journal/api-rules.md");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("codex", () => {
|
|
72
|
+
it("formats as AGENTS.md by default", () => {
|
|
73
|
+
const result = formatMemoryForTool(baseMemory, "codex");
|
|
74
|
+
expect(result.path).toBe("AGENTS.md");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("uses common path prefix for scoped rules", () => {
|
|
78
|
+
const withPaths = { ...baseMemory, paths: ["src/api/routes.ts", "src/api/handlers.ts"] };
|
|
79
|
+
const result = formatMemoryForTool(withPaths, "codex");
|
|
80
|
+
expect(result.path).toBe("src/api/AGENTS.md");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("conductor", () => {
|
|
85
|
+
it("formats as .conductor/rules/*.md", () => {
|
|
86
|
+
const result = formatMemoryForTool(baseMemory, "conductor");
|
|
87
|
+
expect(result.path).toBe(".conductor/rules/api-rules.md");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("zed", () => {
|
|
92
|
+
it("formats as .zed/rules/*.md", () => {
|
|
93
|
+
const result = formatMemoryForTool(baseMemory, "zed");
|
|
94
|
+
expect(result.path).toBe(".zed/rules/api-rules.md");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("vscode-copilot", () => {
|
|
99
|
+
it("formats as .github/copilot-instructions.md", () => {
|
|
100
|
+
const result = formatMemoryForTool(baseMemory, "vscode-copilot");
|
|
101
|
+
expect(result.path).toBe(".github/copilot-instructions.md");
|
|
102
|
+
expect(result.content).toContain("## api-rules");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("pi", () => {
|
|
107
|
+
it("formats as .pi/rules/*.md", () => {
|
|
108
|
+
const result = formatMemoryForTool(baseMemory, "pi");
|
|
109
|
+
expect(result.path).toBe(".pi/rules/api-rules.md");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("raw", () => {
|
|
114
|
+
it("includes full frontmatter", () => {
|
|
115
|
+
const result = formatMemoryForTool(baseMemory, "raw");
|
|
116
|
+
expect(result.path).toBe("memories/api-rules.md");
|
|
117
|
+
expect(result.content).toContain("title: api-rules");
|
|
118
|
+
expect(result.content).toContain("type: instruction");
|
|
119
|
+
expect(result.content).toContain("scope: project");
|
|
120
|
+
expect(result.content).toContain("tags:");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("filename sanitization", () => {
|
|
125
|
+
it("sanitizes special characters in titles", () => {
|
|
126
|
+
const special = { ...baseMemory, title: "My Rule (v2) & Notes!" };
|
|
127
|
+
const result = formatMemoryForTool(special, "zed");
|
|
128
|
+
expect(result.path).not.toContain("(");
|
|
129
|
+
expect(result.path).not.toContain("!");
|
|
130
|
+
expect(result.path).not.toContain("&");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|