becki-mcp 1.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/LICENSE +32 -0
- package/README.md +142 -0
- package/dist/core/ai-sessions.js +325 -0
- package/dist/core/db.js +221 -0
- package/dist/core/init.js +218 -0
- package/dist/core/project-activity.js +225 -0
- package/dist/core/runner.js +109 -0
- package/dist/index.js +3412 -0
- package/package.json +61 -0
package/dist/core/db.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// db.ts — Cross-platform local SQLite cache for Becki Core (#191 sub-task 2)
|
|
2
|
+
//
|
|
3
|
+
// On Studio, the Mac app holds the local SQLite (GRDB). Core has no Mac app,
|
|
4
|
+
// so becki-mcp owns its own cache here.
|
|
5
|
+
//
|
|
6
|
+
// Schema purpose:
|
|
7
|
+
// config — key/value store (replaces UserDefaults from Swift)
|
|
8
|
+
// ai_session_state — per-jsonl tracking (file_size + processed flag) so
|
|
9
|
+
// we don't re-extract from sessions that haven't grown
|
|
10
|
+
// projects — user-registered project directories for sweeper
|
|
11
|
+
// project_activity — debounced summary state for project file events
|
|
12
|
+
// vault_rows_cache — read-through cache of recent NeuraVault rows so
|
|
13
|
+
// MCP queries don't hit Supabase on every call
|
|
14
|
+
//
|
|
15
|
+
// Designed for `better-sqlite3` (synchronous, single-process, fast). DB
|
|
16
|
+
// lives at `<BECKI_HOME>/cache.db` — sibling to install.json + mcp-registry.
|
|
17
|
+
import Database from "better-sqlite3";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { mkdirSync, existsSync } from "fs";
|
|
20
|
+
const SCHEMA_VERSION = 1;
|
|
21
|
+
const MIGRATIONS = {
|
|
22
|
+
1: [
|
|
23
|
+
`CREATE TABLE IF NOT EXISTS config (
|
|
24
|
+
key TEXT PRIMARY KEY,
|
|
25
|
+
value TEXT NOT NULL,
|
|
26
|
+
updated_at INTEGER NOT NULL
|
|
27
|
+
)`,
|
|
28
|
+
`CREATE TABLE IF NOT EXISTS ai_session_state (
|
|
29
|
+
path TEXT PRIMARY KEY,
|
|
30
|
+
file_size INTEGER NOT NULL,
|
|
31
|
+
last_processed_at INTEGER NOT NULL,
|
|
32
|
+
bootstrap_processed INTEGER NOT NULL DEFAULT 0
|
|
33
|
+
)`,
|
|
34
|
+
`CREATE TABLE IF NOT EXISTS projects (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
name TEXT NOT NULL,
|
|
37
|
+
path TEXT NOT NULL UNIQUE,
|
|
38
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
39
|
+
created_at INTEGER NOT NULL
|
|
40
|
+
)`,
|
|
41
|
+
`CREATE TABLE IF NOT EXISTS project_activity (
|
|
42
|
+
project_id TEXT PRIMARY KEY,
|
|
43
|
+
last_summary_at INTEGER NOT NULL,
|
|
44
|
+
last_summary_text TEXT
|
|
45
|
+
)`,
|
|
46
|
+
`CREATE TABLE IF NOT EXISTS vault_rows_cache (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
user_id TEXT NOT NULL,
|
|
49
|
+
type TEXT NOT NULL,
|
|
50
|
+
content TEXT NOT NULL,
|
|
51
|
+
metadata TEXT,
|
|
52
|
+
source_type TEXT,
|
|
53
|
+
source_id TEXT,
|
|
54
|
+
created_at INTEGER NOT NULL,
|
|
55
|
+
updated_at INTEGER NOT NULL
|
|
56
|
+
)`,
|
|
57
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_user_created
|
|
58
|
+
ON vault_rows_cache(user_id, created_at DESC)`,
|
|
59
|
+
`CREATE INDEX IF NOT EXISTS idx_vault_source
|
|
60
|
+
ON vault_rows_cache(source_type, source_id)`,
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
export class BeckiCache {
|
|
64
|
+
db;
|
|
65
|
+
constructor(beckiHome) {
|
|
66
|
+
if (!existsSync(beckiHome))
|
|
67
|
+
mkdirSync(beckiHome, { recursive: true });
|
|
68
|
+
this.db = new Database(join(beckiHome, "cache.db"));
|
|
69
|
+
// WAL gives concurrent readers + one writer without lock contention;
|
|
70
|
+
// safer for a daemon that might be queried while a sweep is mid-write.
|
|
71
|
+
this.db.pragma("journal_mode = WAL");
|
|
72
|
+
this.db.pragma("foreign_keys = ON");
|
|
73
|
+
this.migrate();
|
|
74
|
+
}
|
|
75
|
+
migrate() {
|
|
76
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (version INTEGER PRIMARY KEY)`);
|
|
77
|
+
const row = this.db
|
|
78
|
+
.prepare(`SELECT version FROM schema_meta ORDER BY version DESC LIMIT 1`)
|
|
79
|
+
.get();
|
|
80
|
+
const current = row?.version ?? 0;
|
|
81
|
+
for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
|
|
82
|
+
const steps = MIGRATIONS[v];
|
|
83
|
+
if (!steps)
|
|
84
|
+
continue;
|
|
85
|
+
const tx = this.db.transaction(() => {
|
|
86
|
+
for (const sql of steps)
|
|
87
|
+
this.db.exec(sql);
|
|
88
|
+
this.db
|
|
89
|
+
.prepare(`INSERT INTO schema_meta(version) VALUES (?)`)
|
|
90
|
+
.run(v);
|
|
91
|
+
});
|
|
92
|
+
tx();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ── Config k/v ──────────────────────────────────────────────────────────
|
|
96
|
+
getConfig(key) {
|
|
97
|
+
const row = this.db
|
|
98
|
+
.prepare(`SELECT value FROM config WHERE key = ?`)
|
|
99
|
+
.get(key);
|
|
100
|
+
return row?.value ?? null;
|
|
101
|
+
}
|
|
102
|
+
getConfigJSON(key) {
|
|
103
|
+
const raw = this.getConfig(key);
|
|
104
|
+
if (raw === null)
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
setConfig(key, value) {
|
|
114
|
+
this.db
|
|
115
|
+
.prepare(`INSERT INTO config(key, value, updated_at) VALUES (?, ?, ?)
|
|
116
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
|
117
|
+
.run(key, value, Date.now());
|
|
118
|
+
}
|
|
119
|
+
setConfigJSON(key, value) {
|
|
120
|
+
this.setConfig(key, JSON.stringify(value));
|
|
121
|
+
}
|
|
122
|
+
// ── AI session tracking ────────────────────────────────────────────────
|
|
123
|
+
getSessionState(path) {
|
|
124
|
+
return (this.db
|
|
125
|
+
.prepare(`SELECT * FROM ai_session_state WHERE path = ?`)
|
|
126
|
+
.get(path) ?? null);
|
|
127
|
+
}
|
|
128
|
+
recordSessionProcessed(path, fileSize, isBootstrap) {
|
|
129
|
+
this.db
|
|
130
|
+
.prepare(`INSERT INTO ai_session_state(path, file_size, last_processed_at, bootstrap_processed)
|
|
131
|
+
VALUES (?, ?, ?, ?)
|
|
132
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
133
|
+
file_size = excluded.file_size,
|
|
134
|
+
last_processed_at = excluded.last_processed_at,
|
|
135
|
+
bootstrap_processed = MAX(ai_session_state.bootstrap_processed, excluded.bootstrap_processed)`)
|
|
136
|
+
.run(path, fileSize, Date.now(), isBootstrap ? 1 : 0);
|
|
137
|
+
}
|
|
138
|
+
isBootstrapProcessed(path) {
|
|
139
|
+
const row = this.getSessionState(path);
|
|
140
|
+
return row?.bootstrap_processed === 1;
|
|
141
|
+
}
|
|
142
|
+
// ── Projects ───────────────────────────────────────────────────────────
|
|
143
|
+
upsertProject(p) {
|
|
144
|
+
const active = (p.active ?? true) ? 1 : 0;
|
|
145
|
+
this.db
|
|
146
|
+
.prepare(`INSERT INTO projects(id, name, path, active, created_at)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?)
|
|
148
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
149
|
+
name = excluded.name,
|
|
150
|
+
active = excluded.active`)
|
|
151
|
+
.run(p.id, p.name, p.path, active, Date.now());
|
|
152
|
+
}
|
|
153
|
+
listProjects(activeOnly = true) {
|
|
154
|
+
const sql = activeOnly
|
|
155
|
+
? `SELECT * FROM projects WHERE active = 1 ORDER BY name`
|
|
156
|
+
: `SELECT * FROM projects ORDER BY name`;
|
|
157
|
+
return this.db.prepare(sql).all();
|
|
158
|
+
}
|
|
159
|
+
setProjectActive(id, active) {
|
|
160
|
+
this.db
|
|
161
|
+
.prepare(`UPDATE projects SET active = ? WHERE id = ?`)
|
|
162
|
+
.run(active ? 1 : 0, id);
|
|
163
|
+
}
|
|
164
|
+
// ── Project activity (debounced summary state) ─────────────────────────
|
|
165
|
+
recordProjectSummary(projectId, summary) {
|
|
166
|
+
this.db
|
|
167
|
+
.prepare(`INSERT INTO project_activity(project_id, last_summary_at, last_summary_text)
|
|
168
|
+
VALUES (?, ?, ?)
|
|
169
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
170
|
+
last_summary_at = excluded.last_summary_at,
|
|
171
|
+
last_summary_text = excluded.last_summary_text`)
|
|
172
|
+
.run(projectId, Date.now(), summary);
|
|
173
|
+
}
|
|
174
|
+
getLastProjectSummary(projectId) {
|
|
175
|
+
return (this.db
|
|
176
|
+
.prepare(`SELECT last_summary_at, last_summary_text FROM project_activity WHERE project_id = ?`)
|
|
177
|
+
.get(projectId) ?? null);
|
|
178
|
+
}
|
|
179
|
+
// ── Vault row read-through cache ───────────────────────────────────────
|
|
180
|
+
cacheVaultRow(row) {
|
|
181
|
+
this.db
|
|
182
|
+
.prepare(`INSERT INTO vault_rows_cache
|
|
183
|
+
(id, user_id, type, content, metadata, source_type, source_id, created_at, updated_at)
|
|
184
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
185
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
186
|
+
content = excluded.content,
|
|
187
|
+
metadata = excluded.metadata,
|
|
188
|
+
source_type = excluded.source_type,
|
|
189
|
+
source_id = excluded.source_id,
|
|
190
|
+
updated_at = excluded.updated_at`)
|
|
191
|
+
.run(row.id, row.user_id, row.type, row.content, row.metadata, row.source_type, row.source_id, row.created_at, row.updated_at);
|
|
192
|
+
}
|
|
193
|
+
getRecentCachedRows(userId, limit = 50) {
|
|
194
|
+
return this.db
|
|
195
|
+
.prepare(`SELECT * FROM vault_rows_cache
|
|
196
|
+
WHERE user_id = ?
|
|
197
|
+
ORDER BY created_at DESC
|
|
198
|
+
LIMIT ?`)
|
|
199
|
+
.all(userId, limit);
|
|
200
|
+
}
|
|
201
|
+
countCachedRows(userId) {
|
|
202
|
+
const row = this.db
|
|
203
|
+
.prepare(`SELECT COUNT(*) AS n FROM vault_rows_cache WHERE user_id = ?`)
|
|
204
|
+
.get(userId);
|
|
205
|
+
return row?.n ?? 0;
|
|
206
|
+
}
|
|
207
|
+
// ── Maintenance ────────────────────────────────────────────────────────
|
|
208
|
+
vacuum() {
|
|
209
|
+
this.db.exec(`VACUUM`);
|
|
210
|
+
}
|
|
211
|
+
close() {
|
|
212
|
+
this.db.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Convenience factory — most callers don't need the class directly.
|
|
216
|
+
let cached = null;
|
|
217
|
+
export function getCache(beckiHome) {
|
|
218
|
+
if (!cached)
|
|
219
|
+
cached = new BeckiCache(beckiHome);
|
|
220
|
+
return cached;
|
|
221
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// init.ts — `becki-mcp init` CLI (#191 sub-task 5)
|
|
2
|
+
//
|
|
3
|
+
// First-time setup for Becki Core. Run after `npm i -g becki-mcp`:
|
|
4
|
+
//
|
|
5
|
+
// becki-mcp init → interactive setup
|
|
6
|
+
// becki-mcp init --scan ~/Repos → auto-detect git repos under a path
|
|
7
|
+
// becki-mcp init --bootstrap → run historical AI session ingest too
|
|
8
|
+
//
|
|
9
|
+
// What it does:
|
|
10
|
+
// 1. Ensures `~/.becki/` (or BECKI_HOME) exists with cache.db
|
|
11
|
+
// 2. Detects git repos in user-specified roots (default: ~/Documents, ~/Repos, ~/Code, ~/src)
|
|
12
|
+
// 3. Prompts (or auto-confirms with --yes) which to register as projects
|
|
13
|
+
// 4. Prints the MCP client snippet to add to Claude Desktop / Cursor / etc.
|
|
14
|
+
// 5. (Optional) runs historical AI session bootstrap to backfill last 90 days
|
|
15
|
+
//
|
|
16
|
+
// Auth: install-token flow stays unchanged from the existing index.ts —
|
|
17
|
+
// user authenticates by visiting becki.io and pasting the token, OR by
|
|
18
|
+
// having Becki.app generate one (Studio path). This CLI just makes the
|
|
19
|
+
// daemon usable; auth lives elsewhere.
|
|
20
|
+
import { readdir, stat } from "fs/promises";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
import { createInterface } from "readline/promises";
|
|
24
|
+
import { stdin, stdout } from "process";
|
|
25
|
+
import { randomUUID } from "crypto";
|
|
26
|
+
import { existsSync } from "fs";
|
|
27
|
+
import { BeckiCache } from "./db.js";
|
|
28
|
+
// ── Repo discovery ──────────────────────────────────────────────────────────
|
|
29
|
+
const DEFAULT_SCAN_ROOTS = [
|
|
30
|
+
join(homedir(), "Documents"),
|
|
31
|
+
join(homedir(), "Repos"),
|
|
32
|
+
join(homedir(), "Code"),
|
|
33
|
+
join(homedir(), "src"),
|
|
34
|
+
join(homedir(), "Projects"),
|
|
35
|
+
join(homedir(), "Developer"),
|
|
36
|
+
];
|
|
37
|
+
const SCAN_MAX_DEPTH = 3;
|
|
38
|
+
async function isGitRepo(path) {
|
|
39
|
+
try {
|
|
40
|
+
const s = await stat(join(path, ".git"));
|
|
41
|
+
return s.isDirectory() || s.isFile(); // worktrees use a .git file
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function findRepos(roots) {
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
async function walk(dir, depth) {
|
|
50
|
+
if (depth > SCAN_MAX_DEPTH)
|
|
51
|
+
return;
|
|
52
|
+
if (seen.has(dir))
|
|
53
|
+
return;
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (await isGitRepo(dir)) {
|
|
62
|
+
seen.add(dir);
|
|
63
|
+
return; // do not descend further once we've found a repo
|
|
64
|
+
}
|
|
65
|
+
for (const ent of entries) {
|
|
66
|
+
if (!ent.isDirectory())
|
|
67
|
+
continue;
|
|
68
|
+
if (ent.name.startsWith("."))
|
|
69
|
+
continue; // skip dotdirs at the root level
|
|
70
|
+
await walk(join(dir, ent.name), depth + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const root of roots) {
|
|
74
|
+
if (existsSync(root))
|
|
75
|
+
await walk(root, 0);
|
|
76
|
+
}
|
|
77
|
+
return [...seen].sort();
|
|
78
|
+
}
|
|
79
|
+
function parseArgs(argv) {
|
|
80
|
+
const out = { scan: [], yes: false, bootstrap: false, help: false };
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const a = argv[i];
|
|
83
|
+
if (a === "--help" || a === "-h")
|
|
84
|
+
out.help = true;
|
|
85
|
+
else if (a === "--yes" || a === "-y")
|
|
86
|
+
out.yes = true;
|
|
87
|
+
else if (a === "--bootstrap")
|
|
88
|
+
out.bootstrap = true;
|
|
89
|
+
else if (a === "--scan") {
|
|
90
|
+
const next = argv[i + 1];
|
|
91
|
+
if (next && !next.startsWith("-")) {
|
|
92
|
+
out.scan.push(next);
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
const HELP_TEXT = `becki-mcp init — first-time setup for Becki Core
|
|
100
|
+
|
|
101
|
+
Usage:
|
|
102
|
+
becki-mcp init [options]
|
|
103
|
+
|
|
104
|
+
Options:
|
|
105
|
+
--scan <path> Add a directory to scan for git repos (repeatable)
|
|
106
|
+
--yes, -y Auto-confirm all detected repos (non-interactive)
|
|
107
|
+
--bootstrap After setup, ingest last 90 days of AI sessions
|
|
108
|
+
--help, -h Show this help
|
|
109
|
+
|
|
110
|
+
Defaults scanned: ~/Documents, ~/Repos, ~/Code, ~/src, ~/Projects, ~/Developer
|
|
111
|
+
`;
|
|
112
|
+
// ── Project name heuristic ──────────────────────────────────────────────────
|
|
113
|
+
function projectNameFromPath(repoPath) {
|
|
114
|
+
const parts = repoPath.split("/").filter(Boolean);
|
|
115
|
+
return parts[parts.length - 1] ?? "unnamed";
|
|
116
|
+
}
|
|
117
|
+
// ── MCP config snippet ──────────────────────────────────────────────────────
|
|
118
|
+
function printMcpSnippet() {
|
|
119
|
+
const cmd = process.argv[1] ?? "becki-mcp";
|
|
120
|
+
console.log(`
|
|
121
|
+
Add this to your AI client's MCP config to use Becki Core:
|
|
122
|
+
|
|
123
|
+
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):
|
|
124
|
+
{
|
|
125
|
+
"mcpServers": {
|
|
126
|
+
"becki": {
|
|
127
|
+
"command": "${cmd}"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
Cursor / Windsurf / Codex: same config, under "mcpServers".
|
|
133
|
+
|
|
134
|
+
Then restart your AI client. The 'becki' tool will appear.
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
// ── Main ────────────────────────────────────────────────────────────────────
|
|
138
|
+
export async function runInit(rawArgv, beckiHome) {
|
|
139
|
+
const args = parseArgs(rawArgv);
|
|
140
|
+
if (args.help) {
|
|
141
|
+
console.log(HELP_TEXT);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
console.log(`becki-mcp init — setting up Becki Core in ${beckiHome}\n`);
|
|
145
|
+
const cache = new BeckiCache(beckiHome);
|
|
146
|
+
const existing = cache.listProjects(false);
|
|
147
|
+
if (existing.length > 0) {
|
|
148
|
+
console.log(`${existing.length} project(s) already registered:`);
|
|
149
|
+
for (const p of existing) {
|
|
150
|
+
console.log(` ${p.active ? "✓" : " "} ${p.name} (${p.path})`);
|
|
151
|
+
}
|
|
152
|
+
console.log("");
|
|
153
|
+
}
|
|
154
|
+
const roots = [...DEFAULT_SCAN_ROOTS, ...args.scan];
|
|
155
|
+
console.log(`Scanning for git repos in:`);
|
|
156
|
+
for (const r of roots)
|
|
157
|
+
console.log(` ${r}${existsSync(r) ? "" : " (missing — skipped)"}`);
|
|
158
|
+
console.log("");
|
|
159
|
+
const repos = await findRepos(roots);
|
|
160
|
+
if (repos.length === 0) {
|
|
161
|
+
console.log("No git repos found. Run with --scan <path> to add custom locations.");
|
|
162
|
+
printMcpSnippet();
|
|
163
|
+
cache.close();
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
// De-dupe against already-registered
|
|
167
|
+
const known = new Set(existing.map((p) => p.path));
|
|
168
|
+
const candidates = repos.filter((r) => !known.has(r));
|
|
169
|
+
if (candidates.length === 0) {
|
|
170
|
+
console.log("All discovered repos are already registered.");
|
|
171
|
+
printMcpSnippet();
|
|
172
|
+
cache.close();
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
console.log(`Found ${candidates.length} new repo(s):\n`);
|
|
176
|
+
candidates.forEach((r, i) => {
|
|
177
|
+
console.log(` ${(i + 1).toString().padStart(2)}. ${projectNameFromPath(r)} — ${r}`);
|
|
178
|
+
});
|
|
179
|
+
console.log("");
|
|
180
|
+
let toRegister;
|
|
181
|
+
if (args.yes) {
|
|
182
|
+
toRegister = candidates;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
186
|
+
const answer = (await rl.question("Register which? (all / none / 1,3,5 / comma-list, default: all): ")).trim().toLowerCase();
|
|
187
|
+
rl.close();
|
|
188
|
+
if (answer === "" || answer === "all") {
|
|
189
|
+
toRegister = candidates;
|
|
190
|
+
}
|
|
191
|
+
else if (answer === "none") {
|
|
192
|
+
toRegister = [];
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const indices = answer
|
|
196
|
+
.split(",")
|
|
197
|
+
.map((s) => parseInt(s.trim(), 10) - 1)
|
|
198
|
+
.filter((n) => Number.isInteger(n) && n >= 0 && n < candidates.length);
|
|
199
|
+
toRegister = indices.map((i) => candidates[i]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const path of toRegister) {
|
|
203
|
+
cache.upsertProject({
|
|
204
|
+
id: randomUUID(),
|
|
205
|
+
name: projectNameFromPath(path),
|
|
206
|
+
path,
|
|
207
|
+
active: true,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
console.log(`\nRegistered ${toRegister.length} project(s).\n`);
|
|
211
|
+
if (args.bootstrap) {
|
|
212
|
+
console.log("Bootstrap requested — run `becki-mcp bootstrap` separately to ingest last 90 days of AI sessions.");
|
|
213
|
+
console.log("(Bootstrap is decoupled from init so you can re-run setup without re-paying for extraction.)");
|
|
214
|
+
}
|
|
215
|
+
printMcpSnippet();
|
|
216
|
+
cache.close();
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// project-activity.ts — Cross-platform project file watcher for Becki Core
|
|
2
|
+
// (#191 sub-task 4). Port of ProjectActivityWatcher.swift.
|
|
3
|
+
//
|
|
4
|
+
// Watches user-registered project directories via chokidar (cross-platform
|
|
5
|
+
// FSEvents/inotify abstraction), accumulates touched paths with a 60s
|
|
6
|
+
// debounce, and on quiet fires an activity-summary ingest:
|
|
7
|
+
// "Active development in project X — <timestamp>
|
|
8
|
+
// Branch: <git>
|
|
9
|
+
// Changed: N files across M directories
|
|
10
|
+
// File types: ts: 12, json: 4, ..."
|
|
11
|
+
//
|
|
12
|
+
// The summary is short and dense — designed to embed cleanly so semantic
|
|
13
|
+
// search can answer "what was I working on Tuesday?" without the watcher
|
|
14
|
+
// having to understand the code itself.
|
|
15
|
+
//
|
|
16
|
+
// Mac equivalent used FSEvents directly via Core Services. Chokidar gives
|
|
17
|
+
// us identical semantics on Mac (FSEvents), Linux (inotify), and Windows
|
|
18
|
+
// (ReadDirectoryChangesW), without us writing any platform code.
|
|
19
|
+
import { watch } from "chokidar";
|
|
20
|
+
import { extname, dirname, relative, sep } from "path";
|
|
21
|
+
import { execSync } from "child_process";
|
|
22
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
23
|
+
/** Path components that mean "build artifact / package cache / VCS junk" —
|
|
24
|
+
* changes here don't reflect intentional development work, so they
|
|
25
|
+
* shouldn't burn extraction budget. Match Swift's IGNORED set exactly. */
|
|
26
|
+
const IGNORED_DIRS = new Set([
|
|
27
|
+
".git",
|
|
28
|
+
"node_modules",
|
|
29
|
+
"DerivedData",
|
|
30
|
+
".build",
|
|
31
|
+
".swiftpm",
|
|
32
|
+
"__pycache__",
|
|
33
|
+
".venv",
|
|
34
|
+
"venv",
|
|
35
|
+
"build",
|
|
36
|
+
"dist",
|
|
37
|
+
".next",
|
|
38
|
+
".cache",
|
|
39
|
+
".turbo",
|
|
40
|
+
"target", // Rust
|
|
41
|
+
".gradle", // Java
|
|
42
|
+
"Pods", // CocoaPods
|
|
43
|
+
]);
|
|
44
|
+
const DEBOUNCE_MS = 60_000; // 60s quiet before summarize
|
|
45
|
+
const MAX_PATHS_IN_SUMMARY = 5; // sample path count
|
|
46
|
+
const MAX_FILE_TYPES_IN_SUMMARY = 5;
|
|
47
|
+
const MAX_DIRS_IN_SUMMARY = 3;
|
|
48
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
49
|
+
/** Returns true if ANY component of the path matches an ignored dir name. */
|
|
50
|
+
function isIgnoredPath(absPath) {
|
|
51
|
+
const parts = absPath.split(sep);
|
|
52
|
+
for (const p of parts)
|
|
53
|
+
if (IGNORED_DIRS.has(p))
|
|
54
|
+
return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function readGitBranch(cwd) {
|
|
58
|
+
try {
|
|
59
|
+
const out = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
60
|
+
cwd,
|
|
61
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
62
|
+
timeout: 2_000,
|
|
63
|
+
})
|
|
64
|
+
.toString()
|
|
65
|
+
.trim();
|
|
66
|
+
return out || null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function topN(map, n) {
|
|
73
|
+
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, n);
|
|
74
|
+
}
|
|
75
|
+
// ── Summary generation ─────────────────────────────────────────────────────
|
|
76
|
+
export function summarizeActivity(args) {
|
|
77
|
+
const now = args.now ?? new Date();
|
|
78
|
+
const byExt = new Map();
|
|
79
|
+
const byDir = new Map();
|
|
80
|
+
const relPaths = [];
|
|
81
|
+
for (const abs of args.touched) {
|
|
82
|
+
const ext = extname(abs).replace(/^\./, "").toLowerCase() || "(none)";
|
|
83
|
+
byExt.set(ext, (byExt.get(ext) ?? 0) + 1);
|
|
84
|
+
const rel = relative(args.projectPath, abs);
|
|
85
|
+
const dir = dirname(rel) || ".";
|
|
86
|
+
byDir.set(dir, (byDir.get(dir) ?? 0) + 1);
|
|
87
|
+
relPaths.push(rel);
|
|
88
|
+
}
|
|
89
|
+
// shortest paths first → typically the most representative (avoid 20-deep
|
|
90
|
+
// node_modules-style long paths even though we already filter them).
|
|
91
|
+
relPaths.sort((a, b) => a.length - b.length);
|
|
92
|
+
const sample = relPaths.slice(0, MAX_PATHS_IN_SUMMARY);
|
|
93
|
+
const branch = readGitBranch(args.projectPath);
|
|
94
|
+
const types = topN(byExt, MAX_FILE_TYPES_IN_SUMMARY)
|
|
95
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
96
|
+
.join(", ");
|
|
97
|
+
const dirs = topN(byDir, MAX_DIRS_IN_SUMMARY)
|
|
98
|
+
.map(([k, v]) => `${k} (${v})`)
|
|
99
|
+
.join(", ");
|
|
100
|
+
const lines = [
|
|
101
|
+
`Active development in project '${args.projectName}' — ${now.toISOString()}`,
|
|
102
|
+
branch ? `Branch: ${branch}` : null,
|
|
103
|
+
`Changed: ${args.touched.size} files across ${byDir.size} directories`,
|
|
104
|
+
types ? `File types: ${types}` : null,
|
|
105
|
+
dirs ? `Top dirs: ${dirs}` : null,
|
|
106
|
+
sample.length > 0 ? `Sample: ${sample.join("; ")}` : null,
|
|
107
|
+
].filter((l) => !!l);
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
export class ProjectActivityWatcher {
|
|
111
|
+
opts;
|
|
112
|
+
watchers = new Map(); // projectId → watcher
|
|
113
|
+
pending = new Map(); // projectId → touched abs paths
|
|
114
|
+
timers = new Map(); // projectId → debounce timer
|
|
115
|
+
projects = new Map(); // projectId → row
|
|
116
|
+
debounceMs;
|
|
117
|
+
log;
|
|
118
|
+
constructor(opts) {
|
|
119
|
+
this.opts = opts;
|
|
120
|
+
this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
|
|
121
|
+
this.log = opts.logger ?? (() => { });
|
|
122
|
+
}
|
|
123
|
+
/** Start watching every active project from the local DB. Call once on
|
|
124
|
+
* daemon startup; safe to call again — re-startWatch is idempotent. */
|
|
125
|
+
async startAll() {
|
|
126
|
+
const projects = this.opts.cache.listProjects(true);
|
|
127
|
+
for (const p of projects)
|
|
128
|
+
this.startWatch(p);
|
|
129
|
+
}
|
|
130
|
+
startWatch(p) {
|
|
131
|
+
if (this.watchers.has(p.id))
|
|
132
|
+
return; // already running
|
|
133
|
+
this.projects.set(p.id, p);
|
|
134
|
+
const watcher = watch(p.path, {
|
|
135
|
+
ignored: (path) => isIgnoredPath(path),
|
|
136
|
+
ignoreInitial: true, // initial scan would flood; only react to changes
|
|
137
|
+
persistent: true,
|
|
138
|
+
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
|
|
139
|
+
depth: 12, // generous but bounded
|
|
140
|
+
});
|
|
141
|
+
const onEvent = (path) => this.recordChange(p.id, path);
|
|
142
|
+
watcher.on("add", onEvent);
|
|
143
|
+
watcher.on("change", onEvent);
|
|
144
|
+
watcher.on("unlink", onEvent);
|
|
145
|
+
watcher.on("error", (err) => {
|
|
146
|
+
this.log(`project-activity[${p.name}]: watcher error: ${err.message}`);
|
|
147
|
+
});
|
|
148
|
+
this.watchers.set(p.id, watcher);
|
|
149
|
+
this.log(`project-activity: watching '${p.name}' at ${p.path}`);
|
|
150
|
+
}
|
|
151
|
+
stopWatch(projectId) {
|
|
152
|
+
const w = this.watchers.get(projectId);
|
|
153
|
+
if (w) {
|
|
154
|
+
void w.close();
|
|
155
|
+
this.watchers.delete(projectId);
|
|
156
|
+
}
|
|
157
|
+
const t = this.timers.get(projectId);
|
|
158
|
+
if (t) {
|
|
159
|
+
clearTimeout(t);
|
|
160
|
+
this.timers.delete(projectId);
|
|
161
|
+
}
|
|
162
|
+
this.pending.delete(projectId);
|
|
163
|
+
this.projects.delete(projectId);
|
|
164
|
+
}
|
|
165
|
+
async stopAll() {
|
|
166
|
+
for (const id of [...this.watchers.keys()])
|
|
167
|
+
this.stopWatch(id);
|
|
168
|
+
}
|
|
169
|
+
recordChange(projectId, absPath) {
|
|
170
|
+
let set = this.pending.get(projectId);
|
|
171
|
+
if (!set) {
|
|
172
|
+
set = new Set();
|
|
173
|
+
this.pending.set(projectId, set);
|
|
174
|
+
}
|
|
175
|
+
set.add(absPath);
|
|
176
|
+
// Reset / re-arm debounce. Node's setTimeout drift is fine at 60s scale.
|
|
177
|
+
const existing = this.timers.get(projectId);
|
|
178
|
+
if (existing)
|
|
179
|
+
clearTimeout(existing);
|
|
180
|
+
const t = setTimeout(() => {
|
|
181
|
+
// Async fire-and-forget; we don't block the event loop.
|
|
182
|
+
void this.flush(projectId);
|
|
183
|
+
}, this.debounceMs);
|
|
184
|
+
this.timers.set(projectId, t);
|
|
185
|
+
}
|
|
186
|
+
/** Drain pending paths for a project, build summary, ingest, persist. */
|
|
187
|
+
async flush(projectId) {
|
|
188
|
+
const project = this.projects.get(projectId);
|
|
189
|
+
const touched = this.pending.get(projectId);
|
|
190
|
+
if (!project || !touched || touched.size === 0)
|
|
191
|
+
return;
|
|
192
|
+
this.pending.delete(projectId);
|
|
193
|
+
this.timers.delete(projectId);
|
|
194
|
+
const summary = summarizeActivity({
|
|
195
|
+
projectName: project.name,
|
|
196
|
+
projectPath: project.path,
|
|
197
|
+
touched,
|
|
198
|
+
});
|
|
199
|
+
this.opts.cache.recordProjectSummary(projectId, summary);
|
|
200
|
+
try {
|
|
201
|
+
await this.opts.ingest({
|
|
202
|
+
type: "open_loop",
|
|
203
|
+
content: summary,
|
|
204
|
+
sourceType: "activity",
|
|
205
|
+
sourceId: projectId,
|
|
206
|
+
metadata: {
|
|
207
|
+
project_name: project.name,
|
|
208
|
+
project_path: project.path,
|
|
209
|
+
touched_count: touched.size,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
this.log(`project-activity[${project.name}]: ingested summary (${touched.size} paths)`);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
this.log(`project-activity[${project.name}]: ingest failed: ${err.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Exposed for unit tests
|
|
220
|
+
export const _internals = {
|
|
221
|
+
IGNORED_DIRS,
|
|
222
|
+
DEBOUNCE_MS,
|
|
223
|
+
isIgnoredPath,
|
|
224
|
+
readGitBranch,
|
|
225
|
+
};
|