@engram-cli/engram 0.1.0 → 0.1.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/README.md +52 -18
- package/bundled-backend/app.js +41 -0
- package/bundled-backend/capsule-assembler.js +238 -0
- package/bundled-backend/capsule-store.js +108 -0
- package/bundled-backend/config.js +31 -0
- package/bundled-backend/env-files.js +96 -0
- package/bundled-backend/index.js +22 -0
- package/bundled-backend/intent-filter.js +21 -0
- package/bundled-backend/package.json +7 -0
- package/bundled-backend/project-index.js +74 -0
- package/bundled-backend/project-slug.js +18 -0
- package/bundled-backend/routes/capsule.js +77 -0
- package/bundled-backend/routes/context.js +23 -0
- package/bundled-backend/routes/event.js +70 -0
- package/bundled-backend/routes/global-settings.js +125 -0
- package/bundled-backend/routes/health.js +24 -0
- package/bundled-backend/routes/projects.js +100 -0
- package/bundled-backend/routes/search.js +69 -0
- package/bundled-backend/routes/settings.js +82 -0
- package/bundled-backend/routes/shares.js +92 -0
- package/bundled-backend/supabase.js +47 -0
- package/bundled-backend/supermemory.js +46 -0
- package/dist/cli.js +25 -0
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +54 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +25 -0
- package/dist/client.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +143 -31
- package/dist/mcp.js.map +1 -1
- package/dist/share.d.ts +14 -0
- package/dist/share.d.ts.map +1 -0
- package/dist/share.js +90 -0
- package/dist/share.js.map +1 -0
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +116 -36
- package/dist/start.js.map +1 -1
- package/package.json +10 -4
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
const PROJECTS_FILE = path.join(config.dataDir, "projects.json");
|
|
5
|
+
async function ensureDataDir() {
|
|
6
|
+
await fs.mkdir(config.dataDir, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
export async function readProjects() {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await fs.readFile(PROJECTS_FILE, "utf8");
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
return Array.isArray(parsed.projects) ? parsed.projects : [];
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err.code === "ENOENT")
|
|
16
|
+
return [];
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function writeProjects(projects) {
|
|
21
|
+
await ensureDataDir();
|
|
22
|
+
await fs.writeFile(PROJECTS_FILE, JSON.stringify({ projects }, null, 2), "utf8");
|
|
23
|
+
}
|
|
24
|
+
export async function upsertProject(slug, opts = {}) {
|
|
25
|
+
const projects = await readProjects();
|
|
26
|
+
const seen = opts.seen ?? new Date().toISOString();
|
|
27
|
+
const existing = projects.find((p) => p.slug === slug);
|
|
28
|
+
if (existing) {
|
|
29
|
+
existing.last_seen = seen;
|
|
30
|
+
if (opts.ide && !existing.ides.includes(opts.ide)) {
|
|
31
|
+
existing.ides.push(opts.ide);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
projects.push({
|
|
36
|
+
slug,
|
|
37
|
+
last_seen: seen,
|
|
38
|
+
ides: opts.ide ? [opts.ide] : ["unknown"],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
await writeProjects(projects);
|
|
42
|
+
}
|
|
43
|
+
export async function seedProjectsFromCapsules() {
|
|
44
|
+
const projects = await readProjects();
|
|
45
|
+
if (projects.length)
|
|
46
|
+
return;
|
|
47
|
+
const capsulesDir = path.join(config.dataDir, "capsules");
|
|
48
|
+
try {
|
|
49
|
+
const entries = await fs.readdir(capsulesDir, { withFileTypes: true });
|
|
50
|
+
const newProjects = [];
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!entry.isDirectory())
|
|
53
|
+
continue;
|
|
54
|
+
const current = path.join(capsulesDir, entry.name, "current.json");
|
|
55
|
+
try {
|
|
56
|
+
const raw = await fs.readFile(current, "utf8");
|
|
57
|
+
const capsule = JSON.parse(raw);
|
|
58
|
+
newProjects.push({
|
|
59
|
+
slug: entry.name,
|
|
60
|
+
last_seen: capsule.created_at ?? new Date().toISOString(),
|
|
61
|
+
ides: capsule.source_ide ? [capsule.source_ide] : ["unknown"],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// skip projects without a current capsule
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (newProjects.length)
|
|
69
|
+
await writeProjects(newProjects);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// directory doesn't exist yet; ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const UNSAFE = /(?:\.\.|\/|\\|[\0])/;
|
|
3
|
+
const MAX_SLUG_LEN = 80;
|
|
4
|
+
/** Reject path traversal and unsafe project slugs before filesystem use. */
|
|
5
|
+
export function assertSafeProjectSlug(raw) {
|
|
6
|
+
const slug = raw.trim().toLowerCase();
|
|
7
|
+
if (!slug || slug.length > MAX_SLUG_LEN) {
|
|
8
|
+
throw new Error("invalid_project_slug");
|
|
9
|
+
}
|
|
10
|
+
if (UNSAFE.test(slug) || slug === "." || slug === "..") {
|
|
11
|
+
throw new Error("invalid_project_slug");
|
|
12
|
+
}
|
|
13
|
+
const resolved = path.basename(slug);
|
|
14
|
+
if (resolved !== slug) {
|
|
15
|
+
throw new Error("invalid_project_slug");
|
|
16
|
+
}
|
|
17
|
+
return slug;
|
|
18
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { assembleCapsule } from "../capsule-assembler.js";
|
|
4
|
+
import { ensureCurrentCapsule, listCapsules, loadCapsule, saveCapsule, } from "../capsule-store.js";
|
|
5
|
+
export const capsuleRoutes = new Hono();
|
|
6
|
+
const SaveBody = z.object({
|
|
7
|
+
project: z.string().min(1),
|
|
8
|
+
ide: z.string().optional(),
|
|
9
|
+
project_root: z.string().optional(),
|
|
10
|
+
/** Ignored — one capsule per project; existing id is reused. */
|
|
11
|
+
capsule_id: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
const LoadBody = z.object({
|
|
14
|
+
project: z.string().min(1),
|
|
15
|
+
/** Optional for back-compat; ignored — load always uses current.json. */
|
|
16
|
+
capsule_id: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
capsuleRoutes.post("/capsule/save", async (c) => {
|
|
19
|
+
let body;
|
|
20
|
+
try {
|
|
21
|
+
body = await c.req.json();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
25
|
+
}
|
|
26
|
+
const parsed = SaveBody.safeParse(body);
|
|
27
|
+
if (!parsed.success) {
|
|
28
|
+
return c.json({ error: "invalid_body", details: parsed.error.flatten() }, 400);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const existing = await ensureCurrentCapsule(parsed.data.project);
|
|
32
|
+
const assembled = await assembleCapsule({
|
|
33
|
+
project: parsed.data.project,
|
|
34
|
+
capsuleId: existing?.capsule_id,
|
|
35
|
+
sourceIde: parsed.data.ide,
|
|
36
|
+
// Preserve original created_at on upsert; content is refreshed
|
|
37
|
+
createdAt: existing?.created_at,
|
|
38
|
+
});
|
|
39
|
+
const saved = await saveCapsule(assembled, {
|
|
40
|
+
projectRoot: parsed.data.project_root,
|
|
41
|
+
});
|
|
42
|
+
return c.json(saved, existing ? 200 : 201);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error("[engram] capsule save failed:", err);
|
|
46
|
+
return c.json({
|
|
47
|
+
error: "capsule_save_failed",
|
|
48
|
+
message: err instanceof Error ? err.message : String(err),
|
|
49
|
+
}, 502);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
capsuleRoutes.post("/capsule/load", async (c) => {
|
|
53
|
+
let body;
|
|
54
|
+
try {
|
|
55
|
+
body = await c.req.json();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
59
|
+
}
|
|
60
|
+
const parsed = LoadBody.safeParse(body);
|
|
61
|
+
if (!parsed.success) {
|
|
62
|
+
return c.json({ error: "invalid_body", details: parsed.error.flatten() }, 400);
|
|
63
|
+
}
|
|
64
|
+
const capsule = await loadCapsule(parsed.data.project);
|
|
65
|
+
if (!capsule) {
|
|
66
|
+
return c.json({ error: "not_found" }, 404);
|
|
67
|
+
}
|
|
68
|
+
return c.json(capsule);
|
|
69
|
+
});
|
|
70
|
+
capsuleRoutes.get("/capsule/list", async (c) => {
|
|
71
|
+
const project = c.req.query("project")?.trim();
|
|
72
|
+
if (!project) {
|
|
73
|
+
return c.json({ error: "project_required" }, 400);
|
|
74
|
+
}
|
|
75
|
+
const items = await listCapsules(project);
|
|
76
|
+
return c.json({ project, capsules: items });
|
|
77
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { assembleCapsule } from "../capsule-assembler.js";
|
|
3
|
+
export const contextRoutes = new Hono();
|
|
4
|
+
contextRoutes.get("/context", async (c) => {
|
|
5
|
+
const project = c.req.query("project")?.trim();
|
|
6
|
+
if (!project) {
|
|
7
|
+
return c.json({ error: "project_required" }, 400);
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const capsule = await assembleCapsule({
|
|
11
|
+
project,
|
|
12
|
+
sourceIde: c.req.query("ide") ?? undefined,
|
|
13
|
+
});
|
|
14
|
+
return c.json(capsule);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
console.error("[engram] context failed:", err);
|
|
18
|
+
return c.json({
|
|
19
|
+
error: "context_failed",
|
|
20
|
+
message: err instanceof Error ? err.message : String(err),
|
|
21
|
+
}, 502);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { MemoryEventSchema, deterministicFactString, projectContainerTag, } from "@engram-cli/contracts";
|
|
3
|
+
import { intentFilter } from "../intent-filter.js";
|
|
4
|
+
import { getSupermemory } from "../supermemory.js";
|
|
5
|
+
import { upsertProject } from "../project-index.js";
|
|
6
|
+
export const eventRoutes = new Hono();
|
|
7
|
+
eventRoutes.post("/event", async (c) => {
|
|
8
|
+
let body;
|
|
9
|
+
try {
|
|
10
|
+
body = await c.req.json();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
14
|
+
}
|
|
15
|
+
const parsed = MemoryEventSchema.safeParse(body);
|
|
16
|
+
if (!parsed.success) {
|
|
17
|
+
return c.json({ error: "invalid_event", details: parsed.error.flatten() }, 400);
|
|
18
|
+
}
|
|
19
|
+
const filtered = intentFilter(parsed.data);
|
|
20
|
+
if (!filtered.keep) {
|
|
21
|
+
return c.json({ accepted: false, dropped: true, reason: filtered.reason }, 202);
|
|
22
|
+
}
|
|
23
|
+
const event = filtered.event;
|
|
24
|
+
const containerTag = projectContainerTag(event.project);
|
|
25
|
+
const content = deterministicFactString(event);
|
|
26
|
+
const metadata = {
|
|
27
|
+
ide: event.ide ?? "unknown",
|
|
28
|
+
source: event.source,
|
|
29
|
+
event_type: event.event_type,
|
|
30
|
+
session_id: event.session_id,
|
|
31
|
+
timestamp: event.timestamp,
|
|
32
|
+
};
|
|
33
|
+
if (event.file_path)
|
|
34
|
+
metadata.file_path = event.file_path;
|
|
35
|
+
if (event.line != null)
|
|
36
|
+
metadata.line = event.line;
|
|
37
|
+
if (event.exit_code != null)
|
|
38
|
+
metadata.exit_code = event.exit_code;
|
|
39
|
+
if (event.cwd)
|
|
40
|
+
metadata.cwd = event.cwd;
|
|
41
|
+
if (event.chat_label)
|
|
42
|
+
metadata.chat_label = event.chat_label;
|
|
43
|
+
try {
|
|
44
|
+
const sm = getSupermemory();
|
|
45
|
+
const [result] = await Promise.all([
|
|
46
|
+
sm.add({
|
|
47
|
+
content,
|
|
48
|
+
containerTag,
|
|
49
|
+
metadata,
|
|
50
|
+
}),
|
|
51
|
+
upsertProject(event.project, {
|
|
52
|
+
seen: event.timestamp,
|
|
53
|
+
ide: event.ide ?? (event.source === "terminal" ? "terminal" : event.source === "git" ? "git" : "unknown"),
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
return c.json({
|
|
57
|
+
accepted: true,
|
|
58
|
+
dropped: false,
|
|
59
|
+
containerTag,
|
|
60
|
+
id: result.id,
|
|
61
|
+
}, 202);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
console.error("[engram] add failed:", err);
|
|
65
|
+
return c.json({
|
|
66
|
+
error: "supermemory_add_failed",
|
|
67
|
+
message: err instanceof Error ? err.message : String(err),
|
|
68
|
+
}, 502);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { config, reloadSupermemoryKey } from "../config.js";
|
|
4
|
+
import { detectLlmProvider, envFilePath, LLM_ENV_KEYS, maskKey, readEnvValue, supermemoryApiKeyPath, supermemoryEnvPath, upsertEnvValue, } from "../env-files.js";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
export const globalSettingsRoutes = new Hono();
|
|
8
|
+
const PutBody = z.object({
|
|
9
|
+
supermemory_api_key: z.string().min(1).optional(),
|
|
10
|
+
supermemory_base_url: z.string().url().optional(),
|
|
11
|
+
llm_provider: z.enum(["groq", "openai", "anthropic"]).optional(),
|
|
12
|
+
llm_api_key: z.string().min(1).optional(),
|
|
13
|
+
clear_llm: z.boolean().optional(),
|
|
14
|
+
});
|
|
15
|
+
function buildResponse(restart = false) {
|
|
16
|
+
const envPath = envFilePath();
|
|
17
|
+
const key = readEnvValue(envPath, "SUPERMEMORY_API_KEY") ||
|
|
18
|
+
config.supermemoryApiKey ||
|
|
19
|
+
undefined;
|
|
20
|
+
const llm = detectLlmProvider();
|
|
21
|
+
return {
|
|
22
|
+
supermemory: maskKey(key),
|
|
23
|
+
llm: { provider: llm.provider, configured: llm.configured },
|
|
24
|
+
supermemory_url: readEnvValue(envPath, "SUPERMEMORY_BASE_URL") ||
|
|
25
|
+
config.supermemoryBaseUrl,
|
|
26
|
+
backend_url: `http://localhost:${config.port}`,
|
|
27
|
+
env_path: envPath,
|
|
28
|
+
restart_recommended: restart,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
globalSettingsRoutes.get("/settings/global", (c) => {
|
|
32
|
+
return c.json(buildResponse(false));
|
|
33
|
+
});
|
|
34
|
+
globalSettingsRoutes.put("/settings/global", async (c) => {
|
|
35
|
+
let body;
|
|
36
|
+
try {
|
|
37
|
+
body = await c.req.json();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
41
|
+
}
|
|
42
|
+
const parsed = PutBody.safeParse(body);
|
|
43
|
+
if (!parsed.success) {
|
|
44
|
+
return c.json({ error: "invalid_body", details: parsed.error.flatten() }, 400);
|
|
45
|
+
}
|
|
46
|
+
const data = parsed.data;
|
|
47
|
+
if (!data.supermemory_api_key &&
|
|
48
|
+
!data.llm_api_key &&
|
|
49
|
+
!data.clear_llm &&
|
|
50
|
+
!data.supermemory_base_url) {
|
|
51
|
+
return c.json({ error: "nothing_to_update" }, 400);
|
|
52
|
+
}
|
|
53
|
+
if (data.llm_api_key && !data.llm_provider) {
|
|
54
|
+
return c.json({ error: "llm_provider_required", message: "llm_provider is required when setting llm_api_key" }, 400);
|
|
55
|
+
}
|
|
56
|
+
const envPath = envFilePath();
|
|
57
|
+
let restart = false;
|
|
58
|
+
try {
|
|
59
|
+
if (data.supermemory_api_key) {
|
|
60
|
+
upsertEnvValue(envPath, "SUPERMEMORY_API_KEY", data.supermemory_api_key);
|
|
61
|
+
const base = data.supermemory_base_url ||
|
|
62
|
+
readEnvValue(envPath, "SUPERMEMORY_BASE_URL") ||
|
|
63
|
+
"http://localhost:6767";
|
|
64
|
+
upsertEnvValue(envPath, "SUPERMEMORY_BASE_URL", base);
|
|
65
|
+
// Keep ~/.supermemory/api-key in sync for Local
|
|
66
|
+
const smDir = path.dirname(supermemoryApiKeyPath());
|
|
67
|
+
fs.mkdirSync(smDir, { recursive: true });
|
|
68
|
+
fs.writeFileSync(supermemoryApiKeyPath(), data.supermemory_api_key, "utf8");
|
|
69
|
+
reloadSupermemoryKey(data.supermemory_api_key, base);
|
|
70
|
+
restart = true;
|
|
71
|
+
}
|
|
72
|
+
else if (data.supermemory_base_url) {
|
|
73
|
+
upsertEnvValue(envPath, "SUPERMEMORY_BASE_URL", data.supermemory_base_url);
|
|
74
|
+
reloadSupermemoryKey(config.supermemoryApiKey, data.supermemory_base_url);
|
|
75
|
+
restart = true;
|
|
76
|
+
}
|
|
77
|
+
if (data.clear_llm) {
|
|
78
|
+
const smEnv = supermemoryEnvPath();
|
|
79
|
+
for (const envKey of Object.values(LLM_ENV_KEYS)) {
|
|
80
|
+
// Clear by writing empty? Better: remove lines. upsert empty is fine for "not configured"
|
|
81
|
+
try {
|
|
82
|
+
const text = fs.readFileSync(smEnv, "utf8");
|
|
83
|
+
const next = text
|
|
84
|
+
.split("\n")
|
|
85
|
+
.filter((line) => !Object.values(LLM_ENV_KEYS).some((k) => line.startsWith(`${k}=`)))
|
|
86
|
+
.join("\n");
|
|
87
|
+
fs.writeFileSync(smEnv, next.endsWith("\n") || !next ? next : `${next}\n`, "utf8");
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// no file yet
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (data.llm_api_key && data.llm_provider) {
|
|
95
|
+
const smEnv = supermemoryEnvPath();
|
|
96
|
+
// Clear other provider keys so detectLlmProvider is unambiguous
|
|
97
|
+
for (const [provider, envKey] of Object.entries(LLM_ENV_KEYS)) {
|
|
98
|
+
if (provider === data.llm_provider) {
|
|
99
|
+
upsertEnvValue(smEnv, envKey, data.llm_api_key);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
try {
|
|
103
|
+
const text = fs.readFileSync(smEnv, "utf8");
|
|
104
|
+
const next = text
|
|
105
|
+
.split("\n")
|
|
106
|
+
.filter((line) => !line.startsWith(`${envKey}=`))
|
|
107
|
+
.join("\n");
|
|
108
|
+
fs.writeFileSync(smEnv, next.endsWith("\n") || !next ? next : `${next}\n`, "utf8");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// ignore
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error("[engram] global settings write failed:", err);
|
|
119
|
+
return c.json({
|
|
120
|
+
error: "write_failed",
|
|
121
|
+
message: err instanceof Error ? err.message : String(err),
|
|
122
|
+
}, 500);
|
|
123
|
+
}
|
|
124
|
+
return c.json(buildResponse(restart));
|
|
125
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { detectLlmProvider, envFilePath, maskKey, readEnvValue, } from "../env-files.js";
|
|
4
|
+
import { probeSupermemory } from "../supermemory.js";
|
|
5
|
+
import { isSupabaseConfigured, probeSupabase } from "../supabase.js";
|
|
6
|
+
export const healthRoutes = new Hono();
|
|
7
|
+
healthRoutes.get("/health", async (c) => {
|
|
8
|
+
const supermemory = await probeSupermemory();
|
|
9
|
+
const supabase = isSupabaseConfigured() ? await probeSupabase() : null;
|
|
10
|
+
const envPath = envFilePath();
|
|
11
|
+
const key = readEnvValue(envPath, "SUPERMEMORY_API_KEY") ||
|
|
12
|
+
config.supermemoryApiKey ||
|
|
13
|
+
undefined;
|
|
14
|
+
const llm = detectLlmProvider();
|
|
15
|
+
return c.json({
|
|
16
|
+
ok: true,
|
|
17
|
+
supermemory,
|
|
18
|
+
supabase,
|
|
19
|
+
keys: {
|
|
20
|
+
supermemory: maskKey(key),
|
|
21
|
+
llm: { provider: llm.provider, configured: llm.configured },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getSupermemory } from "../supermemory.js";
|
|
3
|
+
import { projectContainerTag, } from "@engram-cli/contracts";
|
|
4
|
+
import { readProjects, seedProjectsFromCapsules, } from "../project-index.js";
|
|
5
|
+
function metaString(meta, key) {
|
|
6
|
+
const v = meta?.[key];
|
|
7
|
+
if (typeof v === "string" && v !== "")
|
|
8
|
+
return v;
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
function asMeta(meta) {
|
|
12
|
+
return meta ?? undefined;
|
|
13
|
+
}
|
|
14
|
+
async function listDocs(containerTag, limit) {
|
|
15
|
+
const sm = getSupermemory();
|
|
16
|
+
const res = await sm.documents.list({
|
|
17
|
+
containerTags: [containerTag],
|
|
18
|
+
includeContent: true,
|
|
19
|
+
limit,
|
|
20
|
+
sort: "createdAt",
|
|
21
|
+
order: "desc",
|
|
22
|
+
});
|
|
23
|
+
return (res.memories ?? []).map((m) => ({
|
|
24
|
+
id: m.id,
|
|
25
|
+
content: m.content,
|
|
26
|
+
summary: m.summary,
|
|
27
|
+
title: m.title,
|
|
28
|
+
createdAt: m.createdAt,
|
|
29
|
+
metadata: asMeta(m.metadata),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
function previewOf(doc) {
|
|
33
|
+
return (doc.content || doc.summary || doc.title || "").trim().slice(0, 200);
|
|
34
|
+
}
|
|
35
|
+
function ideOf(doc) {
|
|
36
|
+
const source = metaString(doc.metadata, "source");
|
|
37
|
+
return metaString(doc.metadata, "ide") ?? (source === "terminal" ? "terminal" : source === "git" ? "git" : "unknown");
|
|
38
|
+
}
|
|
39
|
+
export const projectRoutes = new Hono();
|
|
40
|
+
projectRoutes.get("/projects", async (c) => {
|
|
41
|
+
await seedProjectsFromCapsules();
|
|
42
|
+
const projects = await readProjects();
|
|
43
|
+
projects.sort((a, b) => b.last_seen.localeCompare(a.last_seen));
|
|
44
|
+
return c.json({ projects });
|
|
45
|
+
});
|
|
46
|
+
projectRoutes.get("/projects/:slug", async (c) => {
|
|
47
|
+
await seedProjectsFromCapsules();
|
|
48
|
+
const slug = c.req.param("slug");
|
|
49
|
+
const projects = await readProjects();
|
|
50
|
+
const project = projects.find((p) => p.slug === slug);
|
|
51
|
+
if (!project)
|
|
52
|
+
return c.json({ error: "project_not_found" }, 404);
|
|
53
|
+
return c.json({ project });
|
|
54
|
+
});
|
|
55
|
+
projectRoutes.get("/projects/:slug/feed", async (c) => {
|
|
56
|
+
const slug = c.req.param("slug");
|
|
57
|
+
const limit = Math.min(Number(c.req.query("limit") ?? 50), 200);
|
|
58
|
+
const containerTag = projectContainerTag(slug);
|
|
59
|
+
const docs = await listDocs(containerTag, limit);
|
|
60
|
+
const items = docs.map((doc) => ({
|
|
61
|
+
id: doc.id,
|
|
62
|
+
event_type: metaString(doc.metadata, "event_type"),
|
|
63
|
+
source: metaString(doc.metadata, "source"),
|
|
64
|
+
ide: ideOf(doc),
|
|
65
|
+
preview: previewOf(doc),
|
|
66
|
+
file_path: metaString(doc.metadata, "file_path"),
|
|
67
|
+
timestamp: metaString(doc.metadata, "timestamp") ?? doc.createdAt,
|
|
68
|
+
}));
|
|
69
|
+
return c.json({ project: slug, total: items.length, items });
|
|
70
|
+
});
|
|
71
|
+
projectRoutes.get("/projects/:slug/clusters", async (c) => {
|
|
72
|
+
const slug = c.req.param("slug");
|
|
73
|
+
const limit = Math.min(Number(c.req.query("limit") ?? 100), 200);
|
|
74
|
+
const containerTag = projectContainerTag(slug);
|
|
75
|
+
const docs = await listDocs(containerTag, limit);
|
|
76
|
+
const byIde = {};
|
|
77
|
+
for (const doc of docs) {
|
|
78
|
+
const ide = ideOf(doc);
|
|
79
|
+
const bucket = byIde[ide] ?? { count: 0, recent: [] };
|
|
80
|
+
bucket.count++;
|
|
81
|
+
if (bucket.recent.length < 5) {
|
|
82
|
+
bucket.recent.push({
|
|
83
|
+
preview: previewOf(doc),
|
|
84
|
+
event_type: metaString(doc.metadata, "event_type"),
|
|
85
|
+
timestamp: metaString(doc.metadata, "timestamp") ?? doc.createdAt ?? undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
byIde[ide] = bucket;
|
|
89
|
+
}
|
|
90
|
+
return c.json({ project: slug, by_ide: byIde });
|
|
91
|
+
});
|
|
92
|
+
projectRoutes.get("/projects/:slug/ides", async (c) => {
|
|
93
|
+
const slug = c.req.param("slug");
|
|
94
|
+
const containerTag = projectContainerTag(slug);
|
|
95
|
+
const docs = await listDocs(containerTag, 1000);
|
|
96
|
+
const ides = new Set();
|
|
97
|
+
for (const doc of docs)
|
|
98
|
+
ides.add(ideOf(doc));
|
|
99
|
+
return c.json({ project: slug, ides: Array.from(ides) });
|
|
100
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { projectContainerTag } from "@engram-cli/contracts";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
import { getSupermemory } from "../supermemory.js";
|
|
6
|
+
export const searchRoutes = new Hono();
|
|
7
|
+
const SearchBody = z.object({
|
|
8
|
+
q: z.string().min(1),
|
|
9
|
+
project: z.string().min(1),
|
|
10
|
+
limit: z.number().int().positive().max(20).optional(),
|
|
11
|
+
});
|
|
12
|
+
/** Self-hosted Local often lacks Workers AI rerank (`x.AI.run`); hybrid chunks still work. */
|
|
13
|
+
function preferRerank() {
|
|
14
|
+
if (process.env.ENGRAM_SEARCH_RERANK === "true")
|
|
15
|
+
return true;
|
|
16
|
+
if (process.env.ENGRAM_SEARCH_RERANK === "false")
|
|
17
|
+
return false;
|
|
18
|
+
return !/localhost|127\.0\.0\.1/.test(config.supermemoryBaseUrl);
|
|
19
|
+
}
|
|
20
|
+
searchRoutes.post("/search", async (c) => {
|
|
21
|
+
let body;
|
|
22
|
+
try {
|
|
23
|
+
body = await c.req.json();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
27
|
+
}
|
|
28
|
+
const parsed = SearchBody.safeParse(body);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
return c.json({ error: "invalid_body", details: parsed.error.flatten() }, 400);
|
|
31
|
+
}
|
|
32
|
+
const containerTag = projectContainerTag(parsed.data.project);
|
|
33
|
+
const sm = getSupermemory();
|
|
34
|
+
const base = {
|
|
35
|
+
q: parsed.data.q,
|
|
36
|
+
containerTag,
|
|
37
|
+
searchMode: "hybrid",
|
|
38
|
+
threshold: 0.55,
|
|
39
|
+
limit: parsed.data.limit ?? 8,
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
let rerank = preferRerank();
|
|
43
|
+
let results;
|
|
44
|
+
try {
|
|
45
|
+
results = await sm.search.memories({ ...base, rerank });
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (!rerank)
|
|
49
|
+
throw err;
|
|
50
|
+
console.warn("[engram] search with rerank failed; retrying without:", err instanceof Error ? err.message : err);
|
|
51
|
+
rerank = false;
|
|
52
|
+
results = await sm.search.memories({ ...base, rerank: false });
|
|
53
|
+
}
|
|
54
|
+
return c.json({
|
|
55
|
+
project: parsed.data.project,
|
|
56
|
+
containerTag,
|
|
57
|
+
rerank,
|
|
58
|
+
total: results.total ?? results.results?.length ?? 0,
|
|
59
|
+
results: results.results ?? [],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error("[engram] search failed:", err);
|
|
64
|
+
return c.json({
|
|
65
|
+
error: "search_failed",
|
|
66
|
+
message: err instanceof Error ? err.message : String(err),
|
|
67
|
+
}, 502);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
const DEFAULT_SETTINGS = {
|
|
7
|
+
retention_days: 14,
|
|
8
|
+
max_mb: 50,
|
|
9
|
+
capture_toggles: {
|
|
10
|
+
file_open: true,
|
|
11
|
+
file_save: true,
|
|
12
|
+
error: true,
|
|
13
|
+
command: true,
|
|
14
|
+
branch_switch: true,
|
|
15
|
+
commit: true,
|
|
16
|
+
code_selection: true,
|
|
17
|
+
clipboard: true,
|
|
18
|
+
chat_summary: true,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const SettingsUpdateSchema = z.object({
|
|
22
|
+
retention_days: z.number().int().min(1).max(365).optional(),
|
|
23
|
+
max_mb: z.number().int().min(1).max(1000).optional(),
|
|
24
|
+
capture_toggles: z.record(z.boolean()).optional(),
|
|
25
|
+
});
|
|
26
|
+
function settingsPath(slug) {
|
|
27
|
+
return path.join(config.dataDir, "settings", `${slug}.json`);
|
|
28
|
+
}
|
|
29
|
+
export async function loadSettings(slug) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await fs.readFile(settingsPath(slug), "utf8");
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
return {
|
|
34
|
+
...DEFAULT_SETTINGS,
|
|
35
|
+
...parsed,
|
|
36
|
+
capture_toggles: {
|
|
37
|
+
...DEFAULT_SETTINGS.capture_toggles,
|
|
38
|
+
...(parsed.capture_toggles ?? {}),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === "ENOENT")
|
|
44
|
+
return { ...DEFAULT_SETTINGS };
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function saveSettings(slug, settings) {
|
|
49
|
+
await fs.mkdir(path.join(config.dataDir, "settings"), { recursive: true });
|
|
50
|
+
await fs.writeFile(settingsPath(slug), JSON.stringify(settings, null, 2), "utf8");
|
|
51
|
+
}
|
|
52
|
+
export const settingsRoutes = new Hono();
|
|
53
|
+
settingsRoutes.get("/projects/:slug/settings", async (c) => {
|
|
54
|
+
const slug = c.req.param("slug");
|
|
55
|
+
const settings = await loadSettings(slug);
|
|
56
|
+
return c.json({ project: slug, settings });
|
|
57
|
+
});
|
|
58
|
+
settingsRoutes.put("/projects/:slug/settings", async (c) => {
|
|
59
|
+
const slug = c.req.param("slug");
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = await c.req.json();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return c.json({ error: "invalid_json" }, 400);
|
|
66
|
+
}
|
|
67
|
+
const parsed = SettingsUpdateSchema.safeParse(body);
|
|
68
|
+
if (!parsed.success) {
|
|
69
|
+
return c.json({ error: "invalid_settings", details: parsed.error.flatten() }, 400);
|
|
70
|
+
}
|
|
71
|
+
const current = await loadSettings(slug);
|
|
72
|
+
const next = {
|
|
73
|
+
retention_days: parsed.data.retention_days ?? current.retention_days,
|
|
74
|
+
max_mb: parsed.data.max_mb ?? current.max_mb,
|
|
75
|
+
capture_toggles: {
|
|
76
|
+
...current.capture_toggles,
|
|
77
|
+
...(parsed.data.capture_toggles ?? {}),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
await saveSettings(slug, next);
|
|
81
|
+
return c.json({ project: slug, settings: next });
|
|
82
|
+
});
|