engrm 0.1.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.
- package/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret scrubbing pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Runs before any observation is saved (even to local SQLite).
|
|
5
|
+
* Patterns from SPEC §6.
|
|
6
|
+
*
|
|
7
|
+
* Pattern definitions are stored as source/flags (not RegExp instances)
|
|
8
|
+
* to avoid shared mutable state from global regex lastIndex.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ScrubPatternDef {
|
|
12
|
+
source: string;
|
|
13
|
+
flags: string;
|
|
14
|
+
replacement: string;
|
|
15
|
+
description: string;
|
|
16
|
+
category: "api_key" | "token" | "password" | "db_url" | "custom";
|
|
17
|
+
severity: "critical" | "high" | "medium" | "low";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_PATTERNS: ScrubPatternDef[] = [
|
|
21
|
+
{
|
|
22
|
+
source: "sk-[a-zA-Z0-9]{20,}",
|
|
23
|
+
flags: "g",
|
|
24
|
+
replacement: "[REDACTED_API_KEY]",
|
|
25
|
+
description: "OpenAI API keys",
|
|
26
|
+
category: "api_key",
|
|
27
|
+
severity: "critical",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
|
|
31
|
+
flags: "g",
|
|
32
|
+
replacement: "[REDACTED_BEARER]",
|
|
33
|
+
description: "Bearer auth tokens",
|
|
34
|
+
category: "token",
|
|
35
|
+
severity: "medium",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
source: "password[=:]\\s*\\S+",
|
|
39
|
+
flags: "gi",
|
|
40
|
+
replacement: "password=[REDACTED]",
|
|
41
|
+
description: "Passwords in config",
|
|
42
|
+
category: "password",
|
|
43
|
+
severity: "high",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
source: "postgresql://[^\\s]+",
|
|
47
|
+
flags: "g",
|
|
48
|
+
replacement: "[REDACTED_DB_URL]",
|
|
49
|
+
description: "PostgreSQL connection strings",
|
|
50
|
+
category: "db_url",
|
|
51
|
+
severity: "high",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
source: "mongodb://[^\\s]+",
|
|
55
|
+
flags: "g",
|
|
56
|
+
replacement: "[REDACTED_DB_URL]",
|
|
57
|
+
description: "MongoDB connection strings",
|
|
58
|
+
category: "db_url",
|
|
59
|
+
severity: "high",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
source: "mysql://[^\\s]+",
|
|
63
|
+
flags: "g",
|
|
64
|
+
replacement: "[REDACTED_DB_URL]",
|
|
65
|
+
description: "MySQL connection strings",
|
|
66
|
+
category: "db_url",
|
|
67
|
+
severity: "high",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
source: "AKIA[A-Z0-9]{16}",
|
|
71
|
+
flags: "g",
|
|
72
|
+
replacement: "[REDACTED_AWS_KEY]",
|
|
73
|
+
description: "AWS access keys",
|
|
74
|
+
category: "api_key",
|
|
75
|
+
severity: "critical",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
source: "ghp_[a-zA-Z0-9]{36}",
|
|
79
|
+
flags: "g",
|
|
80
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
81
|
+
description: "GitHub personal access tokens",
|
|
82
|
+
category: "token",
|
|
83
|
+
severity: "high",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
source: "gho_[a-zA-Z0-9]{36}",
|
|
87
|
+
flags: "g",
|
|
88
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
89
|
+
description: "GitHub OAuth tokens",
|
|
90
|
+
category: "token",
|
|
91
|
+
severity: "high",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
source: "github_pat_[a-zA-Z0-9_]{22,}",
|
|
95
|
+
flags: "g",
|
|
96
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
97
|
+
description: "GitHub fine-grained PATs",
|
|
98
|
+
category: "token",
|
|
99
|
+
severity: "high",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
source: "cvk_[a-f0-9]{64}",
|
|
103
|
+
flags: "g",
|
|
104
|
+
replacement: "[REDACTED_CANDENGO_KEY]",
|
|
105
|
+
description: "Candengo API keys",
|
|
106
|
+
category: "api_key",
|
|
107
|
+
severity: "critical",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
source: "xox[bpras]-[a-zA-Z0-9\\-]+",
|
|
111
|
+
flags: "g",
|
|
112
|
+
replacement: "[REDACTED_SLACK_TOKEN]",
|
|
113
|
+
description: "Slack tokens",
|
|
114
|
+
category: "token",
|
|
115
|
+
severity: "high",
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compile custom patterns from config strings into pattern definitions.
|
|
121
|
+
* Each string is treated as a regex pattern with global flag.
|
|
122
|
+
*/
|
|
123
|
+
function compileCustomPatterns(patterns: string[]): ScrubPatternDef[] {
|
|
124
|
+
const compiled: ScrubPatternDef[] = [];
|
|
125
|
+
for (const pattern of patterns) {
|
|
126
|
+
try {
|
|
127
|
+
// Validate the regex is parseable
|
|
128
|
+
new RegExp(pattern);
|
|
129
|
+
compiled.push({
|
|
130
|
+
source: pattern,
|
|
131
|
+
flags: "g",
|
|
132
|
+
replacement: "[REDACTED_CUSTOM]",
|
|
133
|
+
description: `Custom pattern: ${pattern}`,
|
|
134
|
+
category: "custom",
|
|
135
|
+
severity: "medium",
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
// Skip invalid regex patterns — don't crash the scrubber
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return compiled;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Scrub sensitive content from text.
|
|
146
|
+
* Returns the scrubbed text.
|
|
147
|
+
*/
|
|
148
|
+
export function scrubSecrets(
|
|
149
|
+
text: string,
|
|
150
|
+
customPatterns: string[] = []
|
|
151
|
+
): string {
|
|
152
|
+
let result = text;
|
|
153
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
154
|
+
|
|
155
|
+
for (const pattern of allPatterns) {
|
|
156
|
+
// Fresh RegExp per call — no shared mutable lastIndex state
|
|
157
|
+
result = result.replace(
|
|
158
|
+
new RegExp(pattern.source, pattern.flags),
|
|
159
|
+
pattern.replacement
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if text contains any secrets that would be scrubbed.
|
|
168
|
+
* Useful for sensitivity classification.
|
|
169
|
+
*/
|
|
170
|
+
export function containsSecrets(
|
|
171
|
+
text: string,
|
|
172
|
+
customPatterns: string[] = []
|
|
173
|
+
): boolean {
|
|
174
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
175
|
+
|
|
176
|
+
for (const pattern of allPatterns) {
|
|
177
|
+
if (new RegExp(pattern.source, pattern.flags).test(text)) return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return false;
|
|
181
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Engrm CLI.
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* init — Browser OAuth setup (default)
|
|
7
|
+
* init --token=cmt_x — Setup from provisioning token
|
|
8
|
+
* init --no-browser — Device code flow (headless/SSH)
|
|
9
|
+
* init --manual — Interactive manual setup
|
|
10
|
+
* init --config <f> — Non-interactive setup from a JSON file
|
|
11
|
+
* status — Show current config and database stats
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { hostname, homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import {
|
|
19
|
+
loadConfig,
|
|
20
|
+
saveConfig,
|
|
21
|
+
configExists,
|
|
22
|
+
getConfigDir,
|
|
23
|
+
getSettingsPath,
|
|
24
|
+
getDbPath,
|
|
25
|
+
type Config,
|
|
26
|
+
} from "./config.js";
|
|
27
|
+
import { MemDatabase } from "./storage/sqlite.js";
|
|
28
|
+
import { getOutboxStats } from "./storage/outbox.js";
|
|
29
|
+
import {
|
|
30
|
+
provision,
|
|
31
|
+
ProvisionError,
|
|
32
|
+
DEFAULT_CANDENGO_URL,
|
|
33
|
+
type ProvisionResponse,
|
|
34
|
+
} from "./provisioning/provision.js";
|
|
35
|
+
import { runBrowserAuth } from "./provisioning/browser-auth.js";
|
|
36
|
+
import { registerAll } from "./register.js";
|
|
37
|
+
|
|
38
|
+
const args = process.argv.slice(2);
|
|
39
|
+
const command = args[0];
|
|
40
|
+
|
|
41
|
+
switch (command) {
|
|
42
|
+
case "init":
|
|
43
|
+
await handleInit(args.slice(1));
|
|
44
|
+
break;
|
|
45
|
+
case "status":
|
|
46
|
+
handleStatus();
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
printUsage();
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Init ---
|
|
54
|
+
|
|
55
|
+
async function handleInit(flags: string[]): Promise<void> {
|
|
56
|
+
// --token=cmt_xxx or --token cmt_xxx
|
|
57
|
+
const tokenFlag = flags.find((f) => f.startsWith("--token"));
|
|
58
|
+
if (tokenFlag) {
|
|
59
|
+
let token: string;
|
|
60
|
+
if (tokenFlag.includes("=")) {
|
|
61
|
+
token = tokenFlag.split("=")[1]!;
|
|
62
|
+
} else {
|
|
63
|
+
const idx = flags.indexOf("--token");
|
|
64
|
+
token = flags[idx + 1] ?? "";
|
|
65
|
+
}
|
|
66
|
+
if (!token || !token.startsWith("cmt_")) {
|
|
67
|
+
console.error("Error: --token requires a cmt_ provisioning token");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const url = extractUrlFlag(flags) ?? DEFAULT_CANDENGO_URL;
|
|
71
|
+
await initWithToken(url, token);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --config <path>
|
|
76
|
+
if (flags.includes("--config")) {
|
|
77
|
+
const configIndex = flags.indexOf("--config");
|
|
78
|
+
const configPath = flags[configIndex + 1];
|
|
79
|
+
if (!configPath) {
|
|
80
|
+
console.error("Error: --config requires a file path");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
initFromFile(configPath);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --manual
|
|
88
|
+
if (flags.includes("--manual")) {
|
|
89
|
+
await initManual();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --no-browser (device code flow — placeholder for Phase 4.1b)
|
|
94
|
+
if (flags.includes("--no-browser")) {
|
|
95
|
+
console.error("Device code flow is not yet implemented.");
|
|
96
|
+
console.error("Use: engrm init --token=cmt_xxx");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Default: browser OAuth flow
|
|
101
|
+
const url = extractUrlFlag(flags) ?? DEFAULT_CANDENGO_URL;
|
|
102
|
+
await initWithBrowser(url);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract --url flag value from flags array.
|
|
107
|
+
*/
|
|
108
|
+
function extractUrlFlag(flags: string[]): string | undefined {
|
|
109
|
+
const urlFlag = flags.find((f) => f.startsWith("--url"));
|
|
110
|
+
if (!urlFlag) return undefined;
|
|
111
|
+
if (urlFlag.includes("=")) return urlFlag.split("=")[1];
|
|
112
|
+
const idx = flags.indexOf("--url");
|
|
113
|
+
return flags[idx + 1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Flow C: Provisioning token ---
|
|
117
|
+
|
|
118
|
+
async function initWithToken(baseUrl: string, token: string): Promise<void> {
|
|
119
|
+
if (configExists()) {
|
|
120
|
+
console.log("Existing configuration found. Overwriting...\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log("Exchanging provisioning token...");
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const result = await provision(baseUrl, {
|
|
127
|
+
token,
|
|
128
|
+
device_name: hostname(),
|
|
129
|
+
});
|
|
130
|
+
writeConfigFromProvision(baseUrl, result);
|
|
131
|
+
console.log(`\nConnected as ${result.user_email}`);
|
|
132
|
+
printPostInit();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof ProvisionError) {
|
|
135
|
+
console.error(`\nProvisioning failed: ${error.detail}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Flow A: Browser OAuth ---
|
|
143
|
+
|
|
144
|
+
async function initWithBrowser(baseUrl: string): Promise<void> {
|
|
145
|
+
if (configExists()) {
|
|
146
|
+
console.log("Existing configuration found. Overwriting...\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const { code } = await runBrowserAuth(baseUrl);
|
|
151
|
+
|
|
152
|
+
console.log("Exchanging authorization code...");
|
|
153
|
+
const result = await provision(baseUrl, {
|
|
154
|
+
code,
|
|
155
|
+
device_name: hostname(),
|
|
156
|
+
});
|
|
157
|
+
writeConfigFromProvision(baseUrl, result);
|
|
158
|
+
console.log(`\nConnected as ${result.user_email}`);
|
|
159
|
+
printPostInit();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof ProvisionError) {
|
|
162
|
+
console.error(`\nProvisioning failed: ${error.detail}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
console.error(
|
|
166
|
+
`\nAuthorization failed: ${error instanceof Error ? error.message : String(error)}`
|
|
167
|
+
);
|
|
168
|
+
console.error("Try: engrm init --token=cmt_xxx");
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Shared: write config from provision response ---
|
|
174
|
+
|
|
175
|
+
function writeConfigFromProvision(
|
|
176
|
+
baseUrl: string,
|
|
177
|
+
result: ProvisionResponse
|
|
178
|
+
): void {
|
|
179
|
+
ensureConfigDir();
|
|
180
|
+
|
|
181
|
+
const config: Config = {
|
|
182
|
+
candengo_url: baseUrl,
|
|
183
|
+
candengo_api_key: result.api_key,
|
|
184
|
+
site_id: result.site_id,
|
|
185
|
+
namespace: result.namespace,
|
|
186
|
+
user_id: result.user_id,
|
|
187
|
+
user_email: result.user_email,
|
|
188
|
+
device_id: generateDeviceId(),
|
|
189
|
+
teams: result.teams ?? [],
|
|
190
|
+
sync: {
|
|
191
|
+
enabled: true,
|
|
192
|
+
interval_seconds: 30,
|
|
193
|
+
batch_size: 50,
|
|
194
|
+
},
|
|
195
|
+
search: {
|
|
196
|
+
default_limit: 10,
|
|
197
|
+
local_boost: 1.2,
|
|
198
|
+
scope: "all",
|
|
199
|
+
},
|
|
200
|
+
scrubbing: {
|
|
201
|
+
enabled: true,
|
|
202
|
+
custom_patterns: [],
|
|
203
|
+
default_sensitivity: "shared",
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
saveConfig(config);
|
|
208
|
+
|
|
209
|
+
// Initialise database
|
|
210
|
+
const db = new MemDatabase(getDbPath());
|
|
211
|
+
db.close();
|
|
212
|
+
|
|
213
|
+
console.log(`Configuration saved to ${getSettingsPath()}`);
|
|
214
|
+
console.log(`Database initialised at ${getDbPath()}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Flow D: Manual ---
|
|
218
|
+
|
|
219
|
+
function initFromFile(configPath: string): void {
|
|
220
|
+
if (!existsSync(configPath)) {
|
|
221
|
+
console.error(`Config file not found: ${configPath}`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let parsed: unknown;
|
|
226
|
+
try {
|
|
227
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
228
|
+
parsed = JSON.parse(raw);
|
|
229
|
+
} catch {
|
|
230
|
+
console.error(`Invalid JSON in ${configPath}`);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
235
|
+
console.error("Config file must contain a JSON object");
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const input = parsed as Record<string, unknown>;
|
|
240
|
+
|
|
241
|
+
const required = [
|
|
242
|
+
"candengo_url",
|
|
243
|
+
"candengo_api_key",
|
|
244
|
+
"site_id",
|
|
245
|
+
"namespace",
|
|
246
|
+
"user_id",
|
|
247
|
+
];
|
|
248
|
+
for (const field of required) {
|
|
249
|
+
if (typeof input[field] !== "string" || !(input[field] as string).trim()) {
|
|
250
|
+
console.error(`Missing required field: ${field}`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
ensureConfigDir();
|
|
256
|
+
|
|
257
|
+
const config: Config = {
|
|
258
|
+
candengo_url: (input["candengo_url"] as string).trim(),
|
|
259
|
+
candengo_api_key: (input["candengo_api_key"] as string).trim(),
|
|
260
|
+
site_id: (input["site_id"] as string).trim(),
|
|
261
|
+
namespace: (input["namespace"] as string).trim(),
|
|
262
|
+
user_id: (input["user_id"] as string).trim(),
|
|
263
|
+
user_email:
|
|
264
|
+
typeof input["user_email"] === "string"
|
|
265
|
+
? (input["user_email"] as string).trim()
|
|
266
|
+
: "",
|
|
267
|
+
device_id:
|
|
268
|
+
typeof input["device_id"] === "string"
|
|
269
|
+
? input["device_id"]
|
|
270
|
+
: generateDeviceId(),
|
|
271
|
+
teams: [],
|
|
272
|
+
sync: {
|
|
273
|
+
enabled: true,
|
|
274
|
+
interval_seconds: 30,
|
|
275
|
+
batch_size: 50,
|
|
276
|
+
},
|
|
277
|
+
search: {
|
|
278
|
+
default_limit: 10,
|
|
279
|
+
local_boost: 1.2,
|
|
280
|
+
scope: "all",
|
|
281
|
+
},
|
|
282
|
+
scrubbing: {
|
|
283
|
+
enabled: true,
|
|
284
|
+
custom_patterns: [],
|
|
285
|
+
default_sensitivity: "shared",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
saveConfig(config);
|
|
290
|
+
|
|
291
|
+
const db = new MemDatabase(getDbPath());
|
|
292
|
+
db.close();
|
|
293
|
+
|
|
294
|
+
console.log(`Configuration saved to ${getSettingsPath()}`);
|
|
295
|
+
console.log(`Database initialised at ${getDbPath()}`);
|
|
296
|
+
printPostInit();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function initManual(): Promise<void> {
|
|
300
|
+
const prompt = createPrompter();
|
|
301
|
+
|
|
302
|
+
console.log("Engrm — Interactive Setup\n");
|
|
303
|
+
|
|
304
|
+
if (configExists()) {
|
|
305
|
+
const overwrite = await prompt(
|
|
306
|
+
"Config already exists. Overwrite? [y/N]: "
|
|
307
|
+
);
|
|
308
|
+
if (overwrite.toLowerCase() !== "y") {
|
|
309
|
+
console.log("Aborted.");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const candengoUrl = await prompt(
|
|
315
|
+
"Candengo Vector URL (e.g. https://www.candengo.com): "
|
|
316
|
+
);
|
|
317
|
+
const apiKey = await prompt("API key (cvk_...): ");
|
|
318
|
+
const siteId = await prompt("Site ID: ");
|
|
319
|
+
const namespace = await prompt("Namespace: ");
|
|
320
|
+
const userId = await prompt("User ID: ");
|
|
321
|
+
const userEmail = await prompt("Email (optional): ");
|
|
322
|
+
|
|
323
|
+
if (!candengoUrl || !apiKey || !siteId || !namespace || !userId) {
|
|
324
|
+
console.error("All fields (except email) are required.");
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
ensureConfigDir();
|
|
329
|
+
|
|
330
|
+
const config: Config = {
|
|
331
|
+
candengo_url: candengoUrl.trim(),
|
|
332
|
+
candengo_api_key: apiKey.trim(),
|
|
333
|
+
site_id: siteId.trim(),
|
|
334
|
+
namespace: namespace.trim(),
|
|
335
|
+
user_id: userId.trim(),
|
|
336
|
+
user_email: userEmail.trim(),
|
|
337
|
+
device_id: generateDeviceId(),
|
|
338
|
+
teams: [],
|
|
339
|
+
sync: {
|
|
340
|
+
enabled: true,
|
|
341
|
+
interval_seconds: 30,
|
|
342
|
+
batch_size: 50,
|
|
343
|
+
},
|
|
344
|
+
search: {
|
|
345
|
+
default_limit: 10,
|
|
346
|
+
local_boost: 1.2,
|
|
347
|
+
scope: "all",
|
|
348
|
+
},
|
|
349
|
+
scrubbing: {
|
|
350
|
+
enabled: true,
|
|
351
|
+
custom_patterns: [],
|
|
352
|
+
default_sensitivity: "shared",
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
saveConfig(config);
|
|
357
|
+
|
|
358
|
+
const db = new MemDatabase(getDbPath());
|
|
359
|
+
db.close();
|
|
360
|
+
|
|
361
|
+
console.log(`\nConfiguration saved to ${getSettingsPath()}`);
|
|
362
|
+
console.log(`Database initialised at ${getDbPath()}`);
|
|
363
|
+
printPostInit();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- Status ---
|
|
367
|
+
|
|
368
|
+
function handleStatus(): void {
|
|
369
|
+
if (!configExists()) {
|
|
370
|
+
console.log("Engrm is not configured.");
|
|
371
|
+
console.log("Run: engrm init");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const config = loadConfig();
|
|
376
|
+
console.log("Engrm Status\n");
|
|
377
|
+
console.log(` User: ${config.user_id}`);
|
|
378
|
+
if (config.user_email) {
|
|
379
|
+
console.log(` Email: ${config.user_email}`);
|
|
380
|
+
}
|
|
381
|
+
console.log(` Device: ${config.device_id}`);
|
|
382
|
+
console.log(` Candengo: ${config.candengo_url || "(not set)"}`);
|
|
383
|
+
console.log(` Site: ${config.site_id}`);
|
|
384
|
+
console.log(` Namespace: ${config.namespace}`);
|
|
385
|
+
if (config.teams.length > 0) {
|
|
386
|
+
console.log(
|
|
387
|
+
` Teams: ${config.teams.map((t) => t.name).join(", ")}`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
console.log(
|
|
391
|
+
` Sync: ${config.sync.enabled ? "enabled" : "disabled"}`
|
|
392
|
+
);
|
|
393
|
+
console.log(` Config: ${getSettingsPath()}`);
|
|
394
|
+
console.log(` Database: ${getDbPath()}`);
|
|
395
|
+
|
|
396
|
+
// Check Claude Code registration
|
|
397
|
+
const claudeJson = join(homedir(), ".claude.json");
|
|
398
|
+
const claudeSettings = join(homedir(), ".claude", "settings.json");
|
|
399
|
+
const mcpRegistered = existsSync(claudeJson) && readFileSync(claudeJson, "utf-8").includes('"engrm"');
|
|
400
|
+
const hooksRegistered = existsSync(claudeSettings) && readFileSync(claudeSettings, "utf-8").includes("engrm");
|
|
401
|
+
console.log(` MCP server: ${mcpRegistered ? "registered" : "not registered"}`);
|
|
402
|
+
console.log(` Hooks: ${hooksRegistered ? "registered" : "not registered"}`);
|
|
403
|
+
|
|
404
|
+
if (existsSync(getDbPath())) {
|
|
405
|
+
try {
|
|
406
|
+
const db = new MemDatabase(getDbPath());
|
|
407
|
+
const obsCount = db.getActiveObservationCount();
|
|
408
|
+
const outbox = getOutboxStats(db);
|
|
409
|
+
|
|
410
|
+
// Session summaries count
|
|
411
|
+
const summaryCount = db.db
|
|
412
|
+
.query<{ count: number }, []>(
|
|
413
|
+
"SELECT COUNT(*) as count FROM session_summaries"
|
|
414
|
+
)
|
|
415
|
+
.get()?.count ?? 0;
|
|
416
|
+
|
|
417
|
+
console.log(`\n Active observations: ${obsCount}`);
|
|
418
|
+
console.log(` Session summaries: ${summaryCount}`);
|
|
419
|
+
console.log(
|
|
420
|
+
` Outbox: ${outbox["pending"] ?? 0} pending, ${outbox["failed"] ?? 0} failed, ${outbox["synced"] ?? 0} synced`
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// Security findings (try — table may not exist on old schemas)
|
|
424
|
+
try {
|
|
425
|
+
const findings = db.db
|
|
426
|
+
.query<{ severity: string; count: number }, []>(
|
|
427
|
+
"SELECT severity, COUNT(*) as count FROM security_findings GROUP BY severity"
|
|
428
|
+
)
|
|
429
|
+
.all();
|
|
430
|
+
if (findings.length > 0) {
|
|
431
|
+
const bySeverity = Object.fromEntries(findings.map((f) => [f.severity, f.count]));
|
|
432
|
+
const parts: string[] = [];
|
|
433
|
+
if (bySeverity["critical"]) parts.push(`${bySeverity["critical"]} critical`);
|
|
434
|
+
if (bySeverity["high"]) parts.push(`${bySeverity["high"]} high`);
|
|
435
|
+
if (bySeverity["medium"]) parts.push(`${bySeverity["medium"]} medium`);
|
|
436
|
+
if (bySeverity["low"]) parts.push(`${bySeverity["low"]} low`);
|
|
437
|
+
console.log(` Security findings: ${parts.join(", ")}`);
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
// security_findings table may not exist yet
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
db.close();
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.log(
|
|
446
|
+
`\n Database error: ${error instanceof Error ? error.message : String(error)}`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- Helpers ---
|
|
453
|
+
|
|
454
|
+
function ensureConfigDir(): void {
|
|
455
|
+
const dir = getConfigDir();
|
|
456
|
+
if (!existsSync(dir)) {
|
|
457
|
+
mkdirSync(dir, { recursive: true });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function generateDeviceId(): string {
|
|
462
|
+
const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
463
|
+
const suffix = randomBytes(4).toString("hex");
|
|
464
|
+
return `${host}-${suffix}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function printPostInit(): void {
|
|
468
|
+
console.log("\nRegistering with Claude Code...");
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
const result = registerAll();
|
|
472
|
+
console.log(` MCP server registered → ${result.mcp.path}`);
|
|
473
|
+
console.log(` Hooks registered → ${result.hooks.path}`);
|
|
474
|
+
console.log("\nEngrm is ready! Start a new Claude Code session to use memory.");
|
|
475
|
+
} catch (error) {
|
|
476
|
+
// Registration failed — fall back to manual instructions
|
|
477
|
+
console.log("\nCould not auto-register with Claude Code.");
|
|
478
|
+
console.log(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
479
|
+
console.log("\nManual setup — add to ~/.claude.json:");
|
|
480
|
+
console.log(`
|
|
481
|
+
{
|
|
482
|
+
"mcpServers": {
|
|
483
|
+
"engrm": {
|
|
484
|
+
"type": "stdio",
|
|
485
|
+
"command": "bun",
|
|
486
|
+
"args": ["run", "${process.cwd()}/src/server.ts"]
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function printUsage(): void {
|
|
494
|
+
console.log("Engrm — Memory layer for AI coding agents\n");
|
|
495
|
+
console.log("Usage:");
|
|
496
|
+
console.log(" engrm init Setup via browser (recommended)");
|
|
497
|
+
console.log(" engrm init --token=cmt_xxx Setup from provisioning token");
|
|
498
|
+
console.log(" engrm init --no-browser Setup via device code (SSH/headless)");
|
|
499
|
+
console.log(" engrm init --manual Manual setup (enter all values)");
|
|
500
|
+
console.log(" engrm init --config <file> Setup from JSON file");
|
|
501
|
+
console.log(" engrm status Show status");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Simple line-based prompter using Bun's stdin.
|
|
506
|
+
*/
|
|
507
|
+
function createPrompter(): (question: string) => Promise<string> {
|
|
508
|
+
const decoder = new TextDecoder();
|
|
509
|
+
return async (question: string): Promise<string> => {
|
|
510
|
+
process.stdout.write(question);
|
|
511
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
512
|
+
const line = decoder.decode(chunk).trim();
|
|
513
|
+
return line;
|
|
514
|
+
}
|
|
515
|
+
return "";
|
|
516
|
+
};
|
|
517
|
+
}
|