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.
@@ -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
+ };