@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
package/README.md
CHANGED
|
@@ -55,16 +55,25 @@ Add to `~/.cursor/mcp.json` (or Cursor Settings → MCP):
|
|
|
55
55
|
|
|
56
56
|
Available tools:
|
|
57
57
|
|
|
58
|
+
- `engram_status` — backend health + masked key status
|
|
58
59
|
- `engram_load_capsule` — load the saved project capsule
|
|
60
|
+
- `engram_save_capsule` — freeze current Context as the project capsule
|
|
61
|
+
- `engram_list_capsules` — list saved capsules for a project
|
|
59
62
|
- `engram_context` — assemble live Context from Supermemory
|
|
60
63
|
- `engram_search` — search project memories with a natural-language query
|
|
64
|
+
- `engram_emit` — ingest a MemoryEvent (capture path)
|
|
65
|
+
|
|
66
|
+
MCP resources (when `ENGRAM_PROJECT` is set or cwd resolves a project):
|
|
67
|
+
|
|
68
|
+
- `engram://{project}/context` — live Context capsule
|
|
69
|
+
- `engram://{project}/capsule` — saved capsule snapshot
|
|
61
70
|
|
|
62
71
|
## Development
|
|
63
72
|
|
|
64
73
|
```bash
|
|
65
74
|
cd cli
|
|
66
75
|
npm run dev # tsx src/cli.ts start
|
|
67
|
-
npm run build
|
|
76
|
+
npm run build # also bundles backend into bundled-backend/
|
|
68
77
|
npm run typecheck
|
|
69
78
|
```
|
|
70
79
|
|
|
@@ -76,28 +85,53 @@ bash scripts/smoke.sh
|
|
|
76
85
|
|
|
77
86
|
## Publish (npm org `engram-cli`)
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
Packages on npm are owned by **`shubh1305`**. You must publish while logged in as that user.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm logout
|
|
92
|
+
npm login # use the shubh1305 account
|
|
93
|
+
npm whoami # must print: shubh1305
|
|
94
|
+
|
|
95
|
+
# If 2FA is enabled on the account:
|
|
96
|
+
export NPM_OTP=123456
|
|
97
|
+
|
|
98
|
+
# From repo root — publishes contracts@0.1.1 then engram@0.1.1
|
|
99
|
+
bash scripts/publish-npm.sh
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The script swaps `@engram-cli/contracts` from `file:../packages/contracts` to `^<version>` for the publish, then restores the monorepo `file:` link.
|
|
103
|
+
|
|
104
|
+
### Troubleshooting `E404` on publish
|
|
105
|
+
|
|
106
|
+
`npm error 404 ... you do not have permission` almost always means **wrong npm account**, not a missing package:
|
|
107
|
+
|
|
108
|
+
| Symptom | Fix |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `npm whoami` ≠ `shubh1305` | `npm logout && npm login` as `shubh1305` |
|
|
111
|
+
| `npm whoami` fails (401) | Token expired — run `npm login` again |
|
|
112
|
+
| 2FA enabled | `export NPM_OTP=<6-digit code>` then re-run publish |
|
|
113
|
+
| Still blocked | Confirm access: `npm access list collaborators @engram-cli/engram` |
|
|
114
|
+
|
|
115
|
+
### After publish
|
|
80
116
|
|
|
81
117
|
```bash
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
npm run build
|
|
85
|
-
npm publish --access public
|
|
86
|
-
|
|
87
|
-
# 2) CLI — switch the local file: dep to the registry version, then publish
|
|
88
|
-
cd ../../cli
|
|
89
|
-
# in package.json: "@engram-cli/contracts": "^0.1.0"
|
|
90
|
-
npm install
|
|
91
|
-
npm run build
|
|
92
|
-
npm publish --access public
|
|
93
|
-
# restore "file:../packages/contracts" for monorepo work after publishing
|
|
118
|
+
npx @engram-cli/engram@latest start # bundled backend (works outside monorepo)
|
|
119
|
+
npx @engram-cli/engram@latest init
|
|
94
120
|
```
|
|
95
121
|
|
|
96
|
-
|
|
122
|
+
`engram start` prefers the monorepo `backend/` when present; otherwise it runs the **bundled Hono backend** shipped inside the npm package. Data defaults to `~/.engram/data`. Configure keys via the extension Setup checklist, dashboard `/settings`, or `PUT /settings/global`.
|
|
123
|
+
|
|
124
|
+
**Monorepo dev** (uses your local build, not npm):
|
|
97
125
|
|
|
98
126
|
```bash
|
|
99
|
-
|
|
100
|
-
npx @engram-cli/engram init
|
|
127
|
+
cd cli && npm run build && node dist/cli.js start
|
|
101
128
|
```
|
|
102
129
|
|
|
103
|
-
|
|
130
|
+
### Verified (2026-07-22)
|
|
131
|
+
|
|
132
|
+
| Check | Expected |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `npm view @engram-cli/contracts version` | `0.1.1` |
|
|
135
|
+
| `npm view @engram-cli/engram version` | `0.1.1` |
|
|
136
|
+
| `npx @engram-cli/engram --help` | Includes `share`, `import` |
|
|
137
|
+
| `npx @engram-cli/engram start` (outside repo) | Starts Supermemory + backend |
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { healthRoutes } from "./routes/health.js";
|
|
4
|
+
import { eventRoutes } from "./routes/event.js";
|
|
5
|
+
import { contextRoutes } from "./routes/context.js";
|
|
6
|
+
import { capsuleRoutes } from "./routes/capsule.js";
|
|
7
|
+
import { searchRoutes } from "./routes/search.js";
|
|
8
|
+
import { projectRoutes } from "./routes/projects.js";
|
|
9
|
+
import { settingsRoutes } from "./routes/settings.js";
|
|
10
|
+
import { globalSettingsRoutes } from "./routes/global-settings.js";
|
|
11
|
+
import { sharesRoutes } from "./routes/shares.js";
|
|
12
|
+
const CORS_ORIGINS = [
|
|
13
|
+
"http://localhost:5173",
|
|
14
|
+
"http://localhost:3000",
|
|
15
|
+
"https://engram-context.vercel.app",
|
|
16
|
+
process.env.ENGRAM_LANDING_ORIGIN,
|
|
17
|
+
].filter((o) => Boolean(o));
|
|
18
|
+
export function createApp() {
|
|
19
|
+
const app = new Hono();
|
|
20
|
+
app.use("*", cors({
|
|
21
|
+
origin: CORS_ORIGINS,
|
|
22
|
+
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
23
|
+
allowHeaders: ["Content-Type"],
|
|
24
|
+
credentials: false,
|
|
25
|
+
}));
|
|
26
|
+
app.route("/", healthRoutes);
|
|
27
|
+
app.route("/", eventRoutes);
|
|
28
|
+
app.route("/", contextRoutes);
|
|
29
|
+
app.route("/", capsuleRoutes);
|
|
30
|
+
app.route("/", searchRoutes);
|
|
31
|
+
app.route("/", projectRoutes);
|
|
32
|
+
app.route("/", settingsRoutes);
|
|
33
|
+
app.route("/", globalSettingsRoutes);
|
|
34
|
+
app.route("/", sharesRoutes);
|
|
35
|
+
app.notFound((c) => c.json({ error: "not_found" }, 404));
|
|
36
|
+
app.onError((err, c) => {
|
|
37
|
+
console.error("[engram] unhandled:", err);
|
|
38
|
+
return c.json({ error: "internal_error", message: err.message }, 500);
|
|
39
|
+
});
|
|
40
|
+
return app;
|
|
41
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { CAPS, CAPSULE_SCHEMA_VERSION, truncateBytes, } from "@engram-cli/contracts";
|
|
2
|
+
import { projectContainerTag } from "@engram-cli/contracts";
|
|
3
|
+
import { getSupermemory } from "./supermemory.js";
|
|
4
|
+
function asMeta(raw) {
|
|
5
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
6
|
+
return raw;
|
|
7
|
+
}
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
function metaString(meta, key) {
|
|
11
|
+
const v = meta[key];
|
|
12
|
+
if (typeof v === "string" && v.length > 0)
|
|
13
|
+
return v;
|
|
14
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
15
|
+
return String(v);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
function metaNumber(meta, key) {
|
|
19
|
+
const v = meta[key];
|
|
20
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
21
|
+
return v;
|
|
22
|
+
if (typeof v === "string" && v !== "" && !Number.isNaN(Number(v))) {
|
|
23
|
+
return Number(v);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
async function listRecentDocs(containerTag, limit = 40) {
|
|
28
|
+
const sm = getSupermemory();
|
|
29
|
+
const res = await sm.documents.list({
|
|
30
|
+
containerTags: [containerTag],
|
|
31
|
+
includeContent: true,
|
|
32
|
+
limit,
|
|
33
|
+
sort: "createdAt",
|
|
34
|
+
order: "desc",
|
|
35
|
+
});
|
|
36
|
+
return (res.memories ?? []).map((m) => ({
|
|
37
|
+
id: m.id,
|
|
38
|
+
content: m.content,
|
|
39
|
+
summary: m.summary,
|
|
40
|
+
title: m.title,
|
|
41
|
+
createdAt: m.createdAt,
|
|
42
|
+
metadata: asMeta(m.metadata),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
function eventTypeOf(doc) {
|
|
46
|
+
const t = metaString(doc.metadata, "event_type");
|
|
47
|
+
return t;
|
|
48
|
+
}
|
|
49
|
+
function textOf(doc) {
|
|
50
|
+
return (doc.content || doc.summary || doc.title || "").trim();
|
|
51
|
+
}
|
|
52
|
+
function buildRecentFiles(docs) {
|
|
53
|
+
const files = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const doc of docs) {
|
|
56
|
+
const t = eventTypeOf(doc);
|
|
57
|
+
if (t !== "file_open" && t !== "file_save")
|
|
58
|
+
continue;
|
|
59
|
+
const path = metaString(doc.metadata, "file_path") ||
|
|
60
|
+
textOf(doc).replace(/^(opened|saved):\s*/i, "").trim();
|
|
61
|
+
if (!path || seen.has(path))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(path);
|
|
64
|
+
files.push(path);
|
|
65
|
+
if (files.length >= CAPS.recentFiles)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
return files;
|
|
69
|
+
}
|
|
70
|
+
function buildErrors(docs) {
|
|
71
|
+
const errors = [];
|
|
72
|
+
for (const doc of docs) {
|
|
73
|
+
if (eventTypeOf(doc) !== "error")
|
|
74
|
+
continue;
|
|
75
|
+
const message = textOf(doc).replace(/^error:\s*/i, "").trim() || "error";
|
|
76
|
+
// Strip trailing " in path:line" if present for cleaner message; keep path from meta.
|
|
77
|
+
const file_path = metaString(doc.metadata, "file_path");
|
|
78
|
+
const line = metaNumber(doc.metadata, "line");
|
|
79
|
+
let clean = message;
|
|
80
|
+
if (file_path) {
|
|
81
|
+
const suffix = ` in ${file_path}${line != null ? `:${line}` : ""}`;
|
|
82
|
+
if (clean.endsWith(suffix))
|
|
83
|
+
clean = clean.slice(0, -suffix.length).trim();
|
|
84
|
+
}
|
|
85
|
+
errors.push({ message: clean, file_path, line });
|
|
86
|
+
if (errors.length >= CAPS.errors)
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
return errors;
|
|
90
|
+
}
|
|
91
|
+
function buildFailedCommands(docs) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const doc of docs) {
|
|
94
|
+
if (eventTypeOf(doc) !== "command")
|
|
95
|
+
continue;
|
|
96
|
+
const exit = metaNumber(doc.metadata, "exit_code") ?? 0;
|
|
97
|
+
if (exit === 0)
|
|
98
|
+
continue;
|
|
99
|
+
const raw = textOf(doc);
|
|
100
|
+
// "cmd (exit N): <firstline>\n<stderr...>"
|
|
101
|
+
const withoutPrefix = raw.replace(/^cmd \(exit \d+\):\s*/i, "");
|
|
102
|
+
const [first, ...rest] = withoutPrefix.split("\n");
|
|
103
|
+
const command = (first ?? withoutPrefix).trim();
|
|
104
|
+
const stderr = rest.join("\n").trim();
|
|
105
|
+
out.push({
|
|
106
|
+
command,
|
|
107
|
+
exit_code: exit,
|
|
108
|
+
stderr_tail: stderr
|
|
109
|
+
? truncateBytes(stderr, CAPS.stderrBytes)
|
|
110
|
+
: undefined,
|
|
111
|
+
cwd: metaString(doc.metadata, "cwd"),
|
|
112
|
+
});
|
|
113
|
+
if (out.length >= CAPS.failedCommands)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function buildGit(docs) {
|
|
119
|
+
let branch;
|
|
120
|
+
let last_commit;
|
|
121
|
+
for (const doc of docs) {
|
|
122
|
+
const t = eventTypeOf(doc);
|
|
123
|
+
if (t === "branch_switch" && !branch) {
|
|
124
|
+
branch = textOf(doc).replace(/^branch:\s*/i, "").trim();
|
|
125
|
+
}
|
|
126
|
+
if (t === "commit" && !last_commit) {
|
|
127
|
+
last_commit = textOf(doc).replace(/^commit:\s*/i, "").trim();
|
|
128
|
+
}
|
|
129
|
+
if (branch && last_commit)
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
return { branch, last_commit };
|
|
133
|
+
}
|
|
134
|
+
function buildManualNotes(docs) {
|
|
135
|
+
const notes = [];
|
|
136
|
+
for (const doc of docs) {
|
|
137
|
+
const t = eventTypeOf(doc);
|
|
138
|
+
if (t !== "code_selection" && t !== "clipboard" && t !== "chat_summary") {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let text = textOf(doc);
|
|
142
|
+
if (t === "code_selection")
|
|
143
|
+
text = text.replace(/^selection:\s*/i, "");
|
|
144
|
+
if (t === "clipboard")
|
|
145
|
+
text = text.replace(/^clipboard:\s*/i, "");
|
|
146
|
+
if (t === "chat_summary") {
|
|
147
|
+
text = text.replace(/^chat_summary(?:\[[^\]]*\])?:\s*/i, "");
|
|
148
|
+
}
|
|
149
|
+
notes.push({
|
|
150
|
+
kind: t,
|
|
151
|
+
text: text.trim(),
|
|
152
|
+
file_path: metaString(doc.metadata, "file_path"),
|
|
153
|
+
chat_label: metaString(doc.metadata, "chat_label"),
|
|
154
|
+
});
|
|
155
|
+
if (notes.length >= CAPS.manualNotes)
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
return notes;
|
|
159
|
+
}
|
|
160
|
+
function buildSummary(parts) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
if (parts.profileLine)
|
|
163
|
+
lines.push(parts.profileLine);
|
|
164
|
+
if (parts.git.branch)
|
|
165
|
+
lines.push(`On branch ${parts.git.branch}.`);
|
|
166
|
+
if (parts.git.last_commit)
|
|
167
|
+
lines.push(`Last commit: ${parts.git.last_commit}.`);
|
|
168
|
+
if (parts.recent_files.length) {
|
|
169
|
+
lines.push(`Recent files: ${parts.recent_files.slice(0, 5).join(", ")}.`);
|
|
170
|
+
}
|
|
171
|
+
if (parts.errors.length) {
|
|
172
|
+
const e = parts.errors[0];
|
|
173
|
+
const loc = e.file_path
|
|
174
|
+
? `${e.file_path}${e.line != null ? `:${e.line}` : ""}`
|
|
175
|
+
: "unknown";
|
|
176
|
+
lines.push(`Error at ${loc}: ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
if (parts.failed_commands.length) {
|
|
179
|
+
const c = parts.failed_commands[0];
|
|
180
|
+
lines.push(`Failed: \`${c.command}\` (exit ${c.exit_code}).`);
|
|
181
|
+
}
|
|
182
|
+
if (parts.manual_notes.length) {
|
|
183
|
+
const n = parts.manual_notes[0];
|
|
184
|
+
lines.push(`Note (${n.kind}): ${n.text}`);
|
|
185
|
+
}
|
|
186
|
+
if (!lines.length)
|
|
187
|
+
lines.push("No recent Engram context for this project.");
|
|
188
|
+
return truncateBytes(lines.join(" "), CAPS.summaryBytes);
|
|
189
|
+
}
|
|
190
|
+
export async function assembleCapsule(opts) {
|
|
191
|
+
const containerTag = projectContainerTag(opts.project);
|
|
192
|
+
const sm = getSupermemory();
|
|
193
|
+
let profileLine;
|
|
194
|
+
try {
|
|
195
|
+
const profile = await sm.profile({
|
|
196
|
+
containerTag,
|
|
197
|
+
q: "current work session",
|
|
198
|
+
});
|
|
199
|
+
const dynamic = profile.profile?.dynamic ?? [];
|
|
200
|
+
const staticFacts = profile.profile?.static ?? [];
|
|
201
|
+
const pick = [...dynamic, ...staticFacts].find((s) => typeof s === "string" && s.trim().length > 0);
|
|
202
|
+
if (pick)
|
|
203
|
+
profileLine = String(pick).trim();
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.warn("[engram] profile() failed (continuing with structured fields):", err instanceof Error ? err.message : err);
|
|
207
|
+
}
|
|
208
|
+
const docs = await listRecentDocs(containerTag);
|
|
209
|
+
const recent_files = buildRecentFiles(docs);
|
|
210
|
+
const errors = buildErrors(docs);
|
|
211
|
+
const failed_commands = buildFailedCommands(docs);
|
|
212
|
+
const git = buildGit(docs);
|
|
213
|
+
const manual_notes = buildManualNotes(docs);
|
|
214
|
+
// Prefer IDE from most recent doc metadata when not provided.
|
|
215
|
+
const sourceIde = opts.sourceIde ||
|
|
216
|
+
docs.map((d) => metaString(d.metadata, "ide")).find(Boolean);
|
|
217
|
+
const summary = buildSummary({
|
|
218
|
+
recent_files,
|
|
219
|
+
errors,
|
|
220
|
+
failed_commands,
|
|
221
|
+
git,
|
|
222
|
+
manual_notes,
|
|
223
|
+
profileLine,
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
capsule_id: opts.capsuleId ?? crypto.randomUUID(),
|
|
227
|
+
project: opts.project,
|
|
228
|
+
created_at: opts.createdAt ?? new Date().toISOString(),
|
|
229
|
+
source_ide: sourceIde,
|
|
230
|
+
schema_version: CAPSULE_SCHEMA_VERSION,
|
|
231
|
+
summary,
|
|
232
|
+
recent_files,
|
|
233
|
+
errors,
|
|
234
|
+
failed_commands,
|
|
235
|
+
git,
|
|
236
|
+
manual_notes,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CapsuleSchema, toMarkdown, } from "@engram-cli/contracts";
|
|
4
|
+
import { config } from "./config.js";
|
|
5
|
+
import { assertSafeProjectSlug } from "./project-slug.js";
|
|
6
|
+
const CURRENT_FILE = "current.json";
|
|
7
|
+
function projectDir(project) {
|
|
8
|
+
const safe = assertSafeProjectSlug(project);
|
|
9
|
+
return path.join(config.dataDir, "capsules", safe);
|
|
10
|
+
}
|
|
11
|
+
function currentPath(project) {
|
|
12
|
+
return path.join(projectDir(project), CURRENT_FILE);
|
|
13
|
+
}
|
|
14
|
+
export async function ensureDataDirs() {
|
|
15
|
+
await fs.mkdir(path.join(config.dataDir, "capsules"), { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* One capsule per project at current.json.
|
|
19
|
+
* If missing, migrate the newest legacy {uuid}.json and remove the rest.
|
|
20
|
+
*/
|
|
21
|
+
export async function ensureCurrentCapsule(project) {
|
|
22
|
+
const dir = projectDir(project);
|
|
23
|
+
const current = currentPath(project);
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(current, "utf8");
|
|
26
|
+
return CapsuleSchema.parse(JSON.parse(raw));
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code !== "ENOENT")
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
let files;
|
|
33
|
+
try {
|
|
34
|
+
files = await fs.readdir(dir);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err.code === "ENOENT")
|
|
38
|
+
return null;
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const legacy = [];
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
if (!file.endsWith(".json") || file === CURRENT_FILE)
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
const raw = await fs.readFile(path.join(dir, file), "utf8");
|
|
47
|
+
const capsule = CapsuleSchema.parse(JSON.parse(raw));
|
|
48
|
+
legacy.push({ file, capsule });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// skip corrupt
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!legacy.length)
|
|
55
|
+
return null;
|
|
56
|
+
legacy.sort((a, b) => b.capsule.created_at.localeCompare(a.capsule.created_at));
|
|
57
|
+
const newest = legacy[0];
|
|
58
|
+
await fs.mkdir(dir, { recursive: true });
|
|
59
|
+
await fs.writeFile(current, JSON.stringify(newest.capsule, null, 2), "utf8");
|
|
60
|
+
for (const item of legacy) {
|
|
61
|
+
try {
|
|
62
|
+
await fs.unlink(path.join(dir, item.file));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// best-effort cleanup
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return newest.capsule;
|
|
69
|
+
}
|
|
70
|
+
export async function saveCapsule(capsule, opts) {
|
|
71
|
+
const parsed = CapsuleSchema.parse(capsule);
|
|
72
|
+
const dir = projectDir(parsed.project);
|
|
73
|
+
await fs.mkdir(dir, { recursive: true });
|
|
74
|
+
await fs.writeFile(currentPath(parsed.project), JSON.stringify(parsed, null, 2), "utf8");
|
|
75
|
+
// Remove any leftover legacy UUID files after upsert
|
|
76
|
+
try {
|
|
77
|
+
const files = await fs.readdir(dir);
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
if (file.endsWith(".json") && file !== CURRENT_FILE) {
|
|
80
|
+
await fs.unlink(path.join(dir, file)).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
if (opts?.projectRoot) {
|
|
88
|
+
const mdPath = path.join(opts.projectRoot, "ENGRAM_CONTEXT.md");
|
|
89
|
+
await fs.writeFile(mdPath, toMarkdown(parsed), "utf8");
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
/** Load the single project capsule (migrates legacy files if needed). */
|
|
94
|
+
export async function loadCapsule(project) {
|
|
95
|
+
return ensureCurrentCapsule(project);
|
|
96
|
+
}
|
|
97
|
+
export async function listCapsules(project) {
|
|
98
|
+
const capsule = await ensureCurrentCapsule(project);
|
|
99
|
+
if (!capsule)
|
|
100
|
+
return [];
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
capsule_id: capsule.capsule_id,
|
|
104
|
+
created_at: capsule.created_at,
|
|
105
|
+
summary: capsule.summary.slice(0, 200),
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
4
|
+
export const config = {
|
|
5
|
+
port: Number(process.env.ENGRAM_PORT ?? 7731),
|
|
6
|
+
dataDir: path.resolve(process.env.ENGRAM_DATA_DIR ?? path.join(__dirname, "..", "data")),
|
|
7
|
+
supermemoryApiKey: process.env.SUPERMEMORY_API_KEY ?? "",
|
|
8
|
+
supermemoryBaseUrl: process.env.SUPERMEMORY_BASE_URL ?? "http://localhost:6767",
|
|
9
|
+
databaseUrl: process.env.DATABASE_URL ?? "",
|
|
10
|
+
supabaseUrl: process.env.SUPABASE_URL ?? "",
|
|
11
|
+
supabaseAnonKey: process.env.SUPABASE_ANON_KEY ?? "",
|
|
12
|
+
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
|
|
13
|
+
shareBaseUrl: process.env.ENGRAM_SHARE_BASE_URL ?? "http://localhost:3000",
|
|
14
|
+
};
|
|
15
|
+
/** Hot-reload Supermemory credentials after /settings/global write. */
|
|
16
|
+
export function reloadSupermemoryKey(apiKey, baseUrl) {
|
|
17
|
+
config.supermemoryApiKey = apiKey;
|
|
18
|
+
process.env.SUPERMEMORY_API_KEY = apiKey;
|
|
19
|
+
if (baseUrl) {
|
|
20
|
+
config.supermemoryBaseUrl = baseUrl;
|
|
21
|
+
process.env.SUPERMEMORY_BASE_URL = baseUrl;
|
|
22
|
+
}
|
|
23
|
+
// Lazy import to avoid circular deps at module load
|
|
24
|
+
void import("./supermemory.js").then((m) => m.resetSupermemoryClient());
|
|
25
|
+
}
|
|
26
|
+
export function assertConfig() {
|
|
27
|
+
if (!config.supermemoryApiKey) {
|
|
28
|
+
console.warn("[engram] SUPERMEMORY_API_KEY not set — configure via PUT /settings/global or .env. " +
|
|
29
|
+
"Capture/search will fail until a key is set.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
export const LLM_ENV_KEYS = {
|
|
8
|
+
groq: "GROQ_API_KEY",
|
|
9
|
+
openai: "OPENAI_API_KEY",
|
|
10
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
11
|
+
};
|
|
12
|
+
export function findEnvRoot() {
|
|
13
|
+
if (process.env.ENGRAM_PROJECT_ROOT) {
|
|
14
|
+
return path.resolve(process.env.ENGRAM_PROJECT_ROOT);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
18
|
+
cwd: process.cwd(),
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
21
|
+
}).trim();
|
|
22
|
+
if (gitRoot)
|
|
23
|
+
return gitRoot;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// not a git repo
|
|
27
|
+
}
|
|
28
|
+
// Monorepo fallback: backend/src → repo root
|
|
29
|
+
const monorepo = path.resolve(__dirname, "..", "..");
|
|
30
|
+
if (fs.existsSync(path.join(monorepo, "backend")))
|
|
31
|
+
return monorepo;
|
|
32
|
+
return process.cwd();
|
|
33
|
+
}
|
|
34
|
+
export function envFilePath() {
|
|
35
|
+
return path.join(findEnvRoot(), ".env");
|
|
36
|
+
}
|
|
37
|
+
export function supermemoryEnvPath() {
|
|
38
|
+
return path.join(os.homedir(), ".supermemory", "env");
|
|
39
|
+
}
|
|
40
|
+
export function supermemoryApiKeyPath() {
|
|
41
|
+
return path.join(os.homedir(), ".supermemory", "api-key");
|
|
42
|
+
}
|
|
43
|
+
export function readEnvValue(filePath, key) {
|
|
44
|
+
try {
|
|
45
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
46
|
+
const match = text.match(new RegExp(`^${key}=([^\\r\\n]*)`, "m"));
|
|
47
|
+
return match?.[1]?.trim() || undefined;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function upsertEnvValue(filePath, key, value) {
|
|
54
|
+
const dir = path.dirname(filePath);
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
let text = "";
|
|
57
|
+
try {
|
|
58
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
text = "";
|
|
62
|
+
}
|
|
63
|
+
const line = `${key}=${value}`;
|
|
64
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
65
|
+
if (re.test(text)) {
|
|
66
|
+
text = text.replace(re, line);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
text = text.trimEnd();
|
|
70
|
+
text = text ? `${text}\n${line}\n` : `${line}\n`;
|
|
71
|
+
}
|
|
72
|
+
fs.writeFileSync(filePath, text, "utf8");
|
|
73
|
+
}
|
|
74
|
+
export function maskKey(key) {
|
|
75
|
+
if (!key || key.length < 8) {
|
|
76
|
+
return { configured: Boolean(key) };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
configured: true,
|
|
80
|
+
prefix: `${key.slice(0, 6)}…`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function detectLlmProvider() {
|
|
84
|
+
const envPath = supermemoryEnvPath();
|
|
85
|
+
for (const [provider, envKey] of Object.entries(LLM_ENV_KEYS)) {
|
|
86
|
+
const val = readEnvValue(envPath, envKey);
|
|
87
|
+
if (val)
|
|
88
|
+
return { provider, configured: true };
|
|
89
|
+
}
|
|
90
|
+
// Also check process.env (loaded at boot)
|
|
91
|
+
for (const [provider, envKey] of Object.entries(LLM_ENV_KEYS)) {
|
|
92
|
+
if (process.env[envKey])
|
|
93
|
+
return { provider, configured: true };
|
|
94
|
+
}
|
|
95
|
+
return { provider: null, configured: false };
|
|
96
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { createApp } from "./app.js";
|
|
3
|
+
import { assertConfig, config } from "./config.js";
|
|
4
|
+
import { ensureDataDirs } from "./capsule-store.js";
|
|
5
|
+
import { bootSupermemorySettings } from "./supermemory.js";
|
|
6
|
+
async function main() {
|
|
7
|
+
assertConfig();
|
|
8
|
+
await ensureDataDirs();
|
|
9
|
+
await bootSupermemorySettings();
|
|
10
|
+
const app = createApp();
|
|
11
|
+
console.log(`[engram] listening on http://localhost:${config.port}`);
|
|
12
|
+
console.log(`[engram] data dir: ${config.dataDir}`);
|
|
13
|
+
console.log(`[engram] supermemory: ${config.supermemoryBaseUrl}`);
|
|
14
|
+
serve({
|
|
15
|
+
fetch: app.fetch,
|
|
16
|
+
port: config.port,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
main().catch((err) => {
|
|
20
|
+
console.error("[engram] fatal:", err);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { CAPS, truncateBytes, } from "@engram-cli/contracts";
|
|
2
|
+
const NOISY_SUCCESS = /^(ls|cd|pwd|echo|clear|which|true|false|history|dirname|basename)\b/;
|
|
3
|
+
/** Authoritative ingest filter — clients stay dumb. */
|
|
4
|
+
export function intentFilter(event) {
|
|
5
|
+
const content = event.content.trim();
|
|
6
|
+
if (!content) {
|
|
7
|
+
return { keep: false, reason: "empty_content" };
|
|
8
|
+
}
|
|
9
|
+
if (event.event_type === "command") {
|
|
10
|
+
const exit = event.exit_code ?? 0;
|
|
11
|
+
const firstLine = content.split("\n")[0]?.trim() ?? content;
|
|
12
|
+
if (exit === 0 && NOISY_SUCCESS.test(firstLine)) {
|
|
13
|
+
return { keep: false, reason: "noisy_successful_command" };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const truncated = {
|
|
17
|
+
...event,
|
|
18
|
+
content: truncateBytes(content, CAPS.contentBytes),
|
|
19
|
+
};
|
|
20
|
+
return { keep: true, event: truncated };
|
|
21
|
+
}
|